diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6615a71..d527f06 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -4,11 +4,95 @@ language: "en-US" early_access: false reviews: - profile: "chill" + # Assertive and advisory, as in CheatEngine.SDK: CodeRabbit reviews thoroughly but never approves, requests changes or + # sets a required status. The only required check is "CI / Gate". + profile: "assertive" request_changes_workflow: false high_level_summary: true review_status: false + commit_status: false + fail_commit_status: false poem: false + pre_merge_checks: + # Every check is a warning: none of them can block a merge. No required check enforces the title or changelog + # rules; this review is the only thing that flags a miss. + title: + mode: "warning" + requirements: >- + Pull requests are squash-merged, so the title becomes the commit subject on main (a single-commit pull request + keeps its commit's subject, which follows the same convention); a pull request that contains a commit listed in + .git-blame-ignore-revs is merged with a merge commit instead, whose message repeats the title under GitHub's + "Merge pull request" subject. The convention: an imperative sentence (Add, Fix, Keep...) starting with an + uppercase letter, at most 72 characters, no Conventional Commit prefix such as "feat:" or "fix(scope):", no + trailing period. + description: + mode: "warning" + docstrings: + mode: "off" # CS1591 already fails the build for an undocumented public API + custom_checks: + - name: "Workflow and Gate contract" + 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; a workflow uses pull_request_target, adds a + merge_group trigger or relies on a merge queue; pull-request-ci.yml gains paths or paths-ignore; + actions/checkout omits persist-credentials: false; a job added to ci.yml is missing from the + needs of the gate job; the Gate accepts a skipped or failed result for any job other than sonar, or accepts a + skipped sonar while SONAR_EXPECTED is true; the if: expression of the sonar job and the SONAR_EXPECTED + expression of the Gate differ; a job runs on a label other than windows-2025 or ubuntu-24.04 or has no + timeout-minutes; ci.yml, sonar.yml, codeql.yml or release.yml enables a NuGet package cache (the Sonar + analyzer cache saved only from main is the single exception); a job that runs dotnet does not use + ./.github/actions/setup-dotnet with its locked restore; a run: script expands a user-controlled ${{ }} value + instead of reading it from env:; a PowerShell step runs a native command without checking $LASTEXITCODE; a + workflow grants write permissions at the top level; the caller job id ci named CI or the reusable job gate + named Gate is renamed; SONAR_TOKEN can reach fork or Dependabot runs; or an + uploaded artifact name is not in the reserved list of WorkflowContractTests. Otherwise pass. + - name: "Public API, documentation and changelog" + mode: "warning" + instructions: >- + Applies when public or protected API in libs/, src/ or source-generators/ is added, removed or changes + signature or behavior, when a diagnostic identifier or message changes, or when the template under templates/ + changes what it generates; otherwise pass. Pass when the owning project's PublicAPI.Unshipped.txt declares the + API change, every new public member has XML documentation, the sibling README.md is updated where it + documents the contract, and CHANGELOG.md has an entry under "## [Unreleased]" in the matching category + (Added for an extension, Changed for a semantic correction, Security for a hardened refusal, Deployment for + what is built, packed, pinned or published). Also fail when PublicAPI.Shipped.txt changes outside a release + pull request, which promotes Unshipped to Shipped once, as its last API commit (the 1.0.0 release pull request + started from Shipped files that held only "#nullable enable"). From 1.0.0 on, also fail when a stable public + API listed in a PublicAPI.Shipped.txt file is removed or changes signature or meaning before a new major + version, when the value of a public enum member changes, or when an interface whose remarks say Implementable + gains, loses or changes a member (the README section "Versioning and compatibility" lists them); an interface + whose remarks say Call-only may gain members in a minor release, and an API marked + [Experimental("CECLIENT500x")] may change in one. Fail otherwise. + - name: "SDK boundary and pin" + mode: "warning" + instructions: >- + Fail when: a public Client signature exposes LuaState, CEObject, Owned, a native pointer, an + activation-bound handle or another SDK ownership type, or an SDK type outside the allowlist of + tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs; a Core implementation type becomes + public; Client code declares a native import or binds a Lua global itself (the architecture ratchet in + tests/CheatEngine.Client.Tests/Architecture refuses both); Client code uses the SDK Lua stack or an SDK owner + directly, outside the sanctioned typed SDK surface of that ratchet, without a registered exception that + states its reason and names the missing CheatEngine.SDK primitive; Client code references or suppresses an + [Experimental] CheatEngine.SDK member (CESDK5xxx); a CheatEngine.SDK version literal appears outside + eng/CheatEngineSdk.props; CheatEngineSdkUpperBound or _CheatEngineClientSupportedSdkMajor changes, which moves + the Client to another CheatEngine.SDK major: that is a new Client major version and a deliberate, reviewed + migration, never part of a 1.x change (there is no automated migration guide; the bump procedure and the major + migration checklist are documented in eng/CheatEngineSdk.props itself); a CheatEngine.SDK version change + within the major skips a step of that bump procedure; or a packages.lock.json changes without a project or + package change that explains it. Otherwise pass. + - name: "Qualification evidence" + mode: "warning" + instructions: >- + Pass when every validation claimed in the description states its level: C0 static contract, C1 managed tests + or doubles, C2 native fixture, C3 exact Cheat Engine host with a loaded plugin, C4 several components (two + plugins, a target switch). Fail when a C1 or C2 result, a CI run or a Native AOT publication is presented as + Cheat Engine host qualification; when a capability is described as available without host evidence; when a + document claims a host qualification, a scenario result or a live run id that the committed evidence under + tests/CheatEngine.Client.Tests/LiveQualification/Evidence does not hold; or when an + [Experimental("CECLIENT500x")] attribute, or the [CECLIENT500x] prefix of its PublicAPI lines, is removed + before that evidence covers every scenario its capability requires (RELEASING.md, "Qualification gate"). + Otherwise pass. # The GitHub App performs automatic reviews. No CodeRabbit token, CLI, or workflow is used here. auto_review: @@ -22,7 +106,8 @@ reviews: tools: github-checks: enabled: true - # pull-request-ci.yml runs the pinned, checksum-verified actionlint binary. + # actionlint and zizmor run in the "Lint" job of ci.yml on every event, pinned and checksum-verified; do not + # duplicate them here. actionlint: enabled: false @@ -42,12 +127,18 @@ reviews: instructions: | Preserve a public, high-level Client API. Do not expose LuaState, CEObject, Owned, native pointers, activation-bound handles, or SDK lifetime ownership. SDK values that cross this API must be stable copied values. + Every public interface says in its remarks whether it is Call-only or Implementable: within 1.x a Call-only + interface may gain members in a minor release, an Implementable one never changes. Public enums are int enums + with explicit values that never change meaning; an outcome enum (Kind, Status, State, Effect, Scope) keeps + Unknown = 0. Every public change is declared in PublicAPI.Unshipped.txt, and an experimental API keeps its + [Experimental("CECLIENT500x")] attribute and the [CECLIENT500x] prefix of its PublicAPI lines. - path: "libs/CheatEngine.Client.Core/**" instructions: | Treat Core as the internal execution adapter to CheatEngine.SDK. Verify SDK mappings stay internal, partial effects and operational errors remain observable, and Client resources are released before SDK detachment. - Flag any public Client API that leaks an SDK handle or an SDK lifetime responsibility. + Flag any public Client API that leaks an SDK handle or an SDK lifetime responsibility, and any SDK exception + that can escape a Try* method. - path: "libs/CheatEngine.Client.Hosting/**" instructions: | @@ -57,7 +148,8 @@ reviews: - path: "src/**" instructions: | Keep the facade developer-focused and independent from ABI, Lua binding, native ownership, and dispatcher - details. Direct SDK dependencies must not bypass the Client Core layer. + details. Direct SDK dependencies must not bypass the Client Core layer. The packed README may contain absolute + https links only. - path: "source-generators/**" instructions: | @@ -67,34 +159,94 @@ reviews: - path: "templates/**" instructions: | Keep templates approachable for plugin developers while preserving the explicit SDK bootstrap and generation - assets they require. Package and template smoke coverage belongs in the C# test suite. + assets they require. Package and template smoke coverage belongs in the C# test suite. Template code must not + log addresses, values, Lua text or raw failures. - path: "tests/**" instructions: | - Require behavior tests for success, failure, cancellation, cleanup, and lifecycle transitions. Packaging tests - must consume the packages built by CI and keep realistic Client plus SDK dependencies. + Require behavior tests for success, failure, cancellation, cleanup, and lifecycle transitions. Tests use + xUnit v3 on 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: select tests by trait, never hide one with Skip. The package + consumption tests consume the packages the Release leg packed (CHEATENGINE_CLIENT_PACKAGE_SOURCE) and keep + realistic Client plus SDK dependencies. Distinguish managed, fixture and Native AOT probes from live Cheat + Engine host qualification. + + - path: "tests/**/Architecture/**" + instructions: | + The architecture ratchet freezes the Client's ADR-01 debt. Shrinking a frozen list is always acceptable. + FrozenLuaGlobals stays empty. Growing FrozenLuaUsage requires a registered exception, in the ratchet itself, + with its reason and the name of the CheatEngine.SDK primitive that is missing (AwaitingSdkPrimitive); flag an + exception that names no missing SDK primitive, and a new Permanent entry outside UnsafeLuaClient (there is no + separate migration guide file). SanctionedSdkLuaSurface is exact: each typed SDK Lua member the Client uses + carries its reason. SdkExperimentalApiRatchetTests forbids any reference to or suppression of an [Experimental] + SDK member. - path: ".github/**" instructions: | - Review least privilege, full-SHA action pins, reusable-workflow contracts, artifact producer/consumer paths, - fork and secret guards, and deterministic dependency sources. Do not duplicate the actionlint check already - executed by pull-request-ci.yml. + pull-request-ci.yml, main-ci.yml and release.yml are thin callers of the reusable ci.yml through the job id + ci named CI; there is no merge queue and no merge_group trigger. Exactly one check is required: "CI / Gate"; + never rename it or add a path filter to pull-request-ci.yml. The Gate evaluates toJSON(needs): every ci.yml + job (build-test Debug and Release, aot, sonar, lint, format, dependency-review, lock-files) must succeed, and + only sonar may be skipped, exactly when SONAR_EXPECTED is false (fork or Dependabot pull request, release + run); the sonar if: expression repeats SONAR_EXPECTED textually. A job missing from gate.needs, an unpinned + action, a runner label other than windows-2025 or ubuntu-24.04, a job without timeout-minutes or an + unreserved artifact name fails WorkflowContractTests. The Lint job runs actionlint and the offline zizmor + audits on every event; do not ask for a duplicate CodeRabbit actionlint run. Every dotnet job uses + ./.github/actions/setup-dotnet (locked restore, cache off); no NuGet cache in ci.yml, sonar.yml, codeql.yml or + release.yml. Require SHA-pinned actions, least privilege, persist-credentials: false, user text through env: + only and $LASTEXITCODE checks. CodeQL, Scorecard, zizmor-online and dependency-submission are advisory. + scorecard.yml intentionally has no defaults, env or run steps. Write permissions are granted per job with a + reason comment: outside release.yml, contents: write exists only in the dependency-submission.yml job submit. + Never use pull_request_target. In dependabot.yml, every ecosystem keeps a cooldown of at least 7 days, + CheatEngine.SDK majors stay ignored, and CheatEngine.SDK stays out of every group, version and security updates + alike (DependabotConfigurationTests). + + - path: ".github/workflows/release.yml" + instructions: | + Keep the contract order verify -> ci -> stage -> attest -> draft-release -> publish -> verify-publication -> + finalize-release. The ci job calls ci.yml with package-version and package-retention-days: 90 and never passes + sonar. No package cache and no binary log in the release path. NuGet/login stays in the publish job, behind + the nuget environment and the tag and repository guard; a dispatch run stays a dry run, and stage, the only + job after ci that it runs, keeps a read-only token without id-token. + + - path: "{SECURITY.md,.github/CODEOWNERS,.github/ISSUE_TEMPLATE/**}" + instructions: | + Keep the required fields of the compatibility form aligned with the Client release tuple (package versions, + consumed CheatEngine.SDK identity, native bridge, host profile), and the version placeholders on the Client + line and the pinned CheatEngine.SDK (IssueFormTests). SECURITY.md lists the supported Client line with the + CheatEngine.SDK range it requires. Vulnerabilities are reported privately, never in public issues. CODEOWNERS + stays informational. + + - path: "eng/CheatEngineSdk.props" + instructions: | + The consumed CheatEngine.SDK pin has this single source; there is no separate identity file or bump script. + A version change updates the reviewed identity literals it names (in PackagedClientFeedFixture.cs and + LockFileTests.cs), regenerates every packages.lock.json, and updates the SDK version named in prose, all in + one reviewed pull request. Moving to another major is a deliberate migration, not a dependency bump. - path: "{Directory.Build.props,Directory.Build.targets,Directory.Packages.props,global.json}" instructions: | - Preserve the net10/MTP/package validation contracts and central dependency management. Flag a change that - weakens the Client to SDK layering rules or makes builds and tests less reproducible. + Preserve the net10/MTP/package validation contracts and central dependency management. The exact SDK pin + (rollForward: disable), the analysis level, the NuGet audit policy and the lock-file requirement are guarded by + CHEATENGINECLIENT9030-9032. Flag a change that weakens the Client to SDK layering rules or makes builds and + tests less reproducible. + + - path: "CHANGELOG.md" + instructions: | + Keep a Changelog 1.1.0 with the exact heading "## [Unreleased]" and its four categories in order: Added, + Changed, Security, Deployment. The release workflow reads that section for the release notes. - - path: "{README.md,ROADMAP.md,docs/**}" + - path: "{README.md,ROADMAP.md}" instructions: | Keep the architecture boundary explicit: CheatEngine.SDK owns ABI, native bindings, Lua globals, dispatcher, and native resource ownership; CheatEngine.Client owns high-level workflows, policies, and developer ergonomics. + Never present a managed, fixture or CI result as Cheat Engine host qualification. knowledge_base: code_guidelines: enabled: true filePatterns: - - "CLAUDE.md" + - "CONTRIBUTING.md" linked_repositories: - repository: "CheatEngineNet/CheatEngine.SDK" instructions: | diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..33b6d2c --- /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": false + } + } +} diff --git a/.editorconfig b/.editorconfig index 22262b1..bb87cbd 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,26 +77,132 @@ 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; +# CI's format job additionally runs `dotnet format whitespace --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 +################################################################################ +# Second hardening pass (2026-09): closes rules the .NET 10 SDK ships at +# Suggestion or Silent severity by default. EnforceCodeStyleInBuild only runs +# these analyzers at build time; it does NOT promote their severity. Only +# 'warning' (via TreatWarningsAsErrors) or 'error' fails the build, so every +# dotnet_diagnostic.IDEXXXX / csharp_style_* / dotnet_style_* line below +# carries an explicit severity for that reason - a bare option value with no +# ':severity' suffix silently keeps the SDK default, which is frequently +# Silent and therefore a no-op in CI no matter how the analyzer is configured. +################################################################################ + +# Pattern-matching preference: already 'true' by SDK default, only severity was missing. +# Roslyn only offers the fix when it is provably behavior-preserving. +dotnet_diagnostic.IDE0078.severity = warning +dotnet_diagnostic.IDE0260.severity = warning +dotnet_diagnostic.IDE0019.severity = warning +dotnet_diagnostic.IDE0020.severity = warning +dotnet_diagnostic.IDE0038.severity = warning + +# Local function over lambda when nothing is captured; avoids an implicit +# display-class/delegate allocation, which matters on the AOT src/libs surface. +dotnet_diagnostic.IDE0039.severity = warning +# Mark a capture-free local function/lambda 'static' to prevent the same allocation. +dotnet_diagnostic.IDE0062.severity = warning +dotnet_diagnostic.IDE0320.severity = warning + +# Mechanical, compiler-guaranteed-safe simplifications. +dotnet_diagnostic.IDE0042.severity = warning +dotnet_diagnostic.IDE0016.severity = warning +dotnet_diagnostic.IDE0071.severity = warning +dotnet_diagnostic.IDE0054.severity = warning +dotnet_diagnostic.IDE0074.severity = warning + +# Readonly struct / readonly member: Roslyn only offers the fix when no member +# mutates instance state. C#-level immutability only; the Client owns no native +# interop (the ArchitectureRatchetTests metadata ratchets forbid it), so the +# fix never interacts with memory written through a pointer. +dotnet_diagnostic.IDE0250.severity = warning +dotnet_diagnostic.IDE0251.severity = warning + +# nameof(List<>) needs zero reflection metadata, unlike typeof(T).Name - the +# AOT-friendliest spelling, matching the IsAotCompatible shipping libraries +# and the no-reflection stance the ArchitectureRatchetTests metadata ratchets +# enforce. C# 14+. +csharp_style_prefer_unbound_generic_type_in_nameof = true +dotnet_diagnostic.IDE0340.severity = warning + +# Prefer ArgumentNullException.ThrowIfNull / ArgumentOutOfRangeException throw +# helpers / ObjectDisposedException.ThrowIf over hand-written if-throw blocks. +# Already active at Suggestion under AnalysisLevel=10.0-recommended; Microsoft +# documents the fix as non-breaking. +dotnet_diagnostic.CA1510.severity = warning +dotnet_diagnostic.CA1511.severity = warning +dotnet_diagnostic.CA1512.severity = warning +dotnet_diagnostic.CA1513.severity = warning + +# Parameter-name consistency with the base/interface member it implements. +# The fix is a rename - the same class of fix dotnet/format#348 (open as of +# 2026-09-23) says 'dotnet format' cannot safely apply, so this stays at +# suggestion rather than joining IDE1006's exception list at a higher tier. +dotnet_diagnostic.CA1725.severity = suggestion + +# SYSLIB1054 ('use [LibraryImport], not [DllImport]') ships in-box since +# .NET 7 as part of the runtime's P/Invoke source generator; it lives outside +# AnalysisLevel/AnalysisMode entirely. Client shipping code declares no +# P/Invoke. The only [DllImport] in compiled sources is the deliberate +# NativeImportFixture in ArchitectureRatchetTests, locally suppressed because +# it proves the native-interop ratchet is not vacuous. The MoveFileEx +# declaration in the Hosting buildTransitive targets is an inline MSBuild task +# compiled on consumer machines, outside this analyzer's reach. +dotnet_diagnostic.SYSLIB1054.severity = error + +# Dead-store elimination: a value assigned to a local that is overwritten +# before being read. Safe, mechanical removal fix. +csharp_style_unused_value_assignment_preference = discard_variable:warning +# The build honours the option-format severity above, but `dotnet format` +# only reads dotnet_diagnostic severities, so state it here as well. +dotnet_diagnostic.IDE0059.severity = warning + +# Roslyn's fix only fires for a simple two-branch if/else; a forced ternary +# can still push past max_line_length=120 or read less explicitly than the +# block it replaces, so this stays advisory rather than build-breaking. +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion + +# Pure formatting, zero behavior change, enforced at build time like IDE0055 +# (EnforceCodeStyleInBuild plus TreatWarningsAsErrors). Removes +# operator-precedence ambiguity from mixed binary expressions. +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning +# Same reason as IDE0059 above: let `dotnet format` apply these fixes too. +dotnet_diagnostic.IDE0047.severity = warning +dotnet_diagnostic.IDE0048.severity = warning + +# Flips the VALUE to match this file's own 'explicit types are the +# readability convention' stance (primary-constructor parameters become +# implicitly captured fields with different lifetime/visibility than an +# explicit field + assignment, and complicate per-parameter validation). +# Severity stays suggestion - a design judgment call, not build-breaking +# either way - mirroring IDE1006's philosophy. +csharp_style_prefer_primary_constructors = false:suggestion + +# Advisory only: a backing field is sometimes intentionally explicit, for +# example when it is read with Volatile or updated with Interlocked. +dotnet_style_prefer_auto_properties = true:suggestion + # Rider derives its own "var or explicit type" inspections from the shared # csharp_style_var_* values. Silence those copies so IDE0008 is the one # portable diagnostic developers see, rather than reporting each issue twice. @@ -108,6 +216,11 @@ resharper_suggest_var_or_type_deconstruction_declarations_highlighting = none resharper_possible_null_reference_exception_highlighting = warning # Naming rules keep public contracts recognisable before a reader opens a type. +# File order does not matter: Roslyn orders naming rules by specificity - +# accessibility first, then required modifiers, then symbol kinds - and applies +# the most specific match. Where two rules overlap (a private const field is +# both "private" and "const"), an explicit intersection rule below decides. +# https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/naming-rules#rule-order dotnet_naming_rule.interfaces_must_be_prefixed_i.severity = suggestion dotnet_naming_rule.interfaces_must_be_prefixed_i.symbols = interfaces dotnet_naming_rule.interfaces_must_be_prefixed_i.style = i_pascal_case @@ -121,12 +234,79 @@ dotnet_naming_rule.types_must_be_pascal_case.style = pascal_case dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum, delegate dotnet_naming_style.pascal_case.capitalization = pascal_case +# Async methods and local functions end with "Async" at any accessibility. +# non_private_members_must_be_pascal_case is more specific on accessibility, so +# it would win for non-private async methods; the intersection rule +# non_private_async_methods_must_be_suffixed restores the suffix for them. +# dotnet/roslyn#34833 (still open) means an interface-implementing or +# overriding async method is not always flagged - editor guidance, not a build +# gate, for that reason. +dotnet_naming_rule.async_methods_must_be_suffixed.severity = suggestion +dotnet_naming_rule.async_methods_must_be_suffixed.symbols = async_methods +dotnet_naming_rule.async_methods_must_be_suffixed.style = async_pascal_case +dotnet_naming_symbols.async_methods.applicable_kinds = method, local_function +dotnet_naming_symbols.async_methods.applicable_accessibilities = * +dotnet_naming_symbols.async_methods.required_modifiers = async +dotnet_naming_style.async_pascal_case.required_suffix = Async +dotnet_naming_style.async_pascal_case.capitalization = pascal_case + +dotnet_naming_rule.non_private_async_methods_must_be_suffixed.severity = suggestion +dotnet_naming_rule.non_private_async_methods_must_be_suffixed.symbols = non_private_async_methods +dotnet_naming_rule.non_private_async_methods_must_be_suffixed.style = async_pascal_case +dotnet_naming_symbols.non_private_async_methods.applicable_kinds = method +dotnet_naming_symbols.non_private_async_methods.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +dotnet_naming_symbols.non_private_async_methods.required_modifiers = async + dotnet_naming_rule.non_private_members_must_be_pascal_case.severity = suggestion dotnet_naming_rule.non_private_members_must_be_pascal_case.symbols = non_private_members dotnet_naming_rule.non_private_members_must_be_pascal_case.style = pascal_case dotnet_naming_symbols.non_private_members.applicable_kinds = property, method, event dotnet_naming_symbols.non_private_members.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +# A const or static-readonly field is PascalCase regardless of accessibility +# (BCL convention). const and 'static, readonly' are reported as distinct +# modifier sets by the naming-rule engine, hence two rules. +# private_fields_must_be_underscore_camel_case is more specific on +# accessibility, so it would win for the private subset; the two private_* +# intersection rules restore PascalCase for private constants and private +# static readonly fields. +dotnet_naming_rule.constants_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.constants_must_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constants_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_rule.static_readonly_fields_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.static_readonly_fields_must_be_pascal_case.symbols = static_readonly_fields +dotnet_naming_rule.static_readonly_fields_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.static_readonly_fields.applicable_accessibilities = * +dotnet_naming_symbols.static_readonly_fields.required_modifiers = static, readonly + +dotnet_naming_rule.private_constants_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.private_constants_must_be_pascal_case.symbols = private_constant_fields +dotnet_naming_rule.private_constants_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_rule.private_static_readonly_fields_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_must_be_pascal_case.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly + +# non_private_members (above) covers properties/methods/events but not +# fields, and enum members are reported as fields too - both previously +# matched zero naming rule anywhere in this file. +dotnet_naming_rule.non_private_fields_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_fields_must_be_pascal_case.symbols = non_private_fields +dotnet_naming_rule.non_private_fields_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.non_private_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected + dotnet_naming_rule.private_fields_must_be_underscore_camel_case.severity = suggestion dotnet_naming_rule.private_fields_must_be_underscore_camel_case.symbols = private_fields dotnet_naming_rule.private_fields_must_be_underscore_camel_case.style = underscore_camel_case @@ -139,12 +319,12 @@ dotnet_naming_style.underscore_camel_case.capitalization = camel_case # build. Shipping projects already enable it; tests and samples intentionally # retain it as an editor suggestion rather than acquiring build-only XML output. - -# CS8762: Parameter must have a non-null value when exiting in some condition. -dotnet_diagnostic.CS8762.severity = none - [libs/**.cs] dotnet_diagnostic.IDE0005.severity = error +# The Client's RS0048 guard: every shipping library must carry both +# PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt, so a public member can +# never ship without an explicit API baseline entry. +dotnet_public_api_analyzer.require_api_files = true [src/**.cs] dotnet_diagnostic.IDE0005.severity = error diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..9604191 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,9 @@ +# Commits that only reformat or mechanically rename code. Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs +# Apply repository formatting +e5b1e0dde918a54c0dd628737bb7ad86d36c1c7f +# Reformat the plugin template sources with tabs +d3d261e15bc520dd5cb24a2237bdefd417887532 +# Apply the hardened code style to every source file +c9080ea19c5db2d4f9aa6ff3025923aed3dde5d7 +# Rename private constants, static fields and async methods +cf07fdc6e39fce692679cb4074ba8bf36ff523a7 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d563918 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Working trees use CRLF on every OS; this file owns line-ending normalization while .editorconfig governs formatting. +* text=auto eol=crlf + +# Binary assets are never normalized. +*.dll binary +*.exe binary +*.nupkg binary +*.snupkg binary +*.png binary +*.ico binary +*.snk binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..77089b0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,16 @@ +# Informational only. "Require review from Code Owners" stays off while the project has a single active maintainer: +# an author cannot approve their own pull request, so a required code-owner review would block every change. +# Owners must have write access to the repository; invalid lines are skipped silently. Syntax: +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# The sensitive paths below repeat the default owner so that a second owner can be added in one place. + +* @AriusII + +/.github/ @AriusII +/eng/ @AriusII +/Directory.Build.props @AriusII +/Directory.Build.targets @AriusII +/Directory.Packages.props @AriusII +/global.json @AriusII +/nuget.config @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 0000000..b27d964 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,76 @@ +name: Bug report +description: A Client API, package or template behaves incorrectly; load or host problems use the compatibility report. +title: "Bug: " +labels: [ bug ] +body: + - type: markdown + attributes: + value: | + If the problem depends on the Cheat Engine build, its .NET runtime policy or plugin loading, use the + **Compatibility report** form instead: it records the exact tuple needed to investigate. + Security problems go through private vulnerability reporting (see SECURITY.md), never through an issue. + - type: input + id: client-version + attributes: + label: CheatEngine.Client version + description: All CheatEngine.Client* packages share one version. Read it from your packages.lock.json. + placeholder: 1.0.0 + validations: + required: true + - type: input + id: sdk-version + attributes: + label: CheatEngine.SDK version resolved in packages.lock.json + placeholder: 2.0.0 + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Abstractions (public contracts, options, failures, leases) + - Fluent API + - Core (processes, memory, AOB scanning, inspection and symbols, tables, protected Lua) + - Experimental APIs (value scans, allocations, instructions, Auto Assembler patches; CECLIENT5001-5004) + - Hosting and dependency injection + - Lua source generator + - Template (dotnet new ceplugin) + - Packaging + - Other + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: A minimal code sample or project is best. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behaviour + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behaviour + description: Include the exact exception, or the CheatEngineFailure kind and operation name. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Redacted logs + render: text + - type: checkboxes + id: confirmations + attributes: + label: Confirmations + options: + - label: I removed user names, private paths and target-memory contents from this report. + required: true + - label: This is not a security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 0000000..622f1ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,206 @@ +name: Compatibility report +description: A CheatEngine.Client plugin fails to load or behaves differently on a specific Cheat Engine setup. +title: "Compatibility: " +labels: [ bug ] +body: + - type: markdown + attributes: + value: | + A compatibility problem can only be investigated against an exact tuple: the Client packages, the + CheatEngine.SDK package they resolved (version and fingerprint), the native Lua protection bridge, the Lua DLL + and the Cheat Engine build actually used, the .NET runtime policy of Cheat Engine, the plugin build options and + the load profile. A file name or a version alone does not identify it. + + The profile targeted for qualification is Cheat Engine 7.7.0.10621 x64 (`cheatengine-x86_64.exe`) loading a + managed plugin through hostfxr (`ce-7.7.0.10621-x64-managed-hostfxr`). Whether that profile has been qualified, + and at which level, is stated by the qualification matrix of the release you use, not by this form. Reports + from other builds and load profiles are welcome too; say which one you used. + + Compute every hash with `Get-FileHash -Algorithm SHA256`. Remove user names and private paths. + Never attach Cheat Engine binaries, target binaries, authorization manifests or raw DebugView dumps. + Security problems go through private vulnerability reporting (see SECURITY.md), never through an issue. + - type: input + id: client-version + attributes: + label: CheatEngine.Client version + description: All CheatEngine.Client* packages share one version. Read it from your plugin's packages.lock.json. + placeholder: 1.0.0 + validations: + required: true + - type: input + id: sdk-version + attributes: + label: CheatEngine.SDK version + description: The `resolved` version of CheatEngine.SDK in your plugin's packages.lock.json. + placeholder: 2.0.0 + validations: + required: true + - type: input + id: sdk-content-hash + attributes: + label: CheatEngine.SDK contentHash + description: The `contentHash` of the same CheatEngine.SDK entry, copied from packages.lock.json (not recomputed). + placeholder: NLEdZYJ9...lQ== + validations: + required: true + - type: input + id: bridge-sha256 + attributes: + label: Native bridge SHA-256 + description: SHA-256 of `cheatengine-sdk-lua-bridge.dll` in your plugin output folder. + validations: + required: true + - type: input + id: bridge-fingerprint + attributes: + label: Native bridge source fingerprint (optional) + description: The `:` fingerprint, when the SDK release notes or the plugin log print it. + - type: input + id: lua-dll-sha256 + attributes: + label: Lua DLL SHA-256 + description: SHA-256 of `lua53-64.dll` in the Cheat Engine folder (the Lua runtime the plugin actually binds to). + validations: + required: true + - type: input + id: ce-version + attributes: + label: Cheat Engine version and executable + description: The version from Help > About, and the name of the executable you started. + placeholder: 7.7.0.10621, cheatengine-x86_64.exe + validations: + required: true + - type: input + id: ce-exe-sha256 + attributes: + label: Cheat Engine executable SHA-256 + description: SHA-256 of the executable you started. + validations: + required: true + - type: dropdown + id: ce-build + attributes: + label: Cheat Engine build and architecture + options: + - cheatengine-x86_64.exe (x64) + - cheatengine-x86_64-SSE4-AVX2.exe (x64, not a profiled build) + - Started through the Cheat Engine.exe launcher (not a profiled route) + - x86 Cheat Engine (not supported) + - Other + validations: + required: true + - type: input + id: runtimeconfig-sha256 + attributes: + label: ce.runtimeconfig.json SHA-256 + description: >- + SHA-256 of `ce.runtimeconfig.json` in the Cheat Engine folder. Record it as it is; do not change it for + this report. + validations: + required: true + - type: dropdown + id: runtimeconfig-state + attributes: + label: ce.runtimeconfig.json state + options: + - As installed by Cheat Engine + - Modified locally (describe the difference under Actual behaviour) + - Unknown + validations: + required: true + - type: textarea + id: dotnet-runtimes + attributes: + label: Installed .NET runtimes + description: Output of `dotnet --list-runtimes`, with private paths removed. + render: text + validations: + required: true + - type: dropdown + id: load-profile + attributes: + label: Plugin load profile + options: + - Managed plugin loaded through hostfxr (supported route, targeted for qualification) + - NativeAOT plugin (not supported) + - Historical CLR route (not supported) + - I do not know + validations: + required: true + - type: textarea + id: plugin-build + attributes: + label: Plugin build settings + description: | + `dotnet --version`, TargetFramework, RuntimeIdentifier, PlatformTarget, PublishAot and SelfContained if set, + whether the plugin was created with `dotnet new ceplugin`, and whether the Client packages came from nuget.org + or another feed. + render: text + validations: + required: true + - type: dropdown + id: observed-level + attributes: + label: Where did you observe the problem? + description: The qualification level of the observation. A build or unit-test result is not a Cheat Engine result. + options: + - Build, restore, analyzer or packaging output (C0) + - Unit tests or test doubles (C1) + - Native fixture outside Cheat Engine (C2) + - Cheat Engine with this plugin loaded (C3) + - Cheat Engine with several plugins, or after a target switch (C4) + validations: + required: true + - type: input + id: target + attributes: + label: Target process + description: Architecture (x64 or x86) and kind (local process, CEServer, file opened as a process). + placeholder: x64 local process + validations: + required: true + - type: input + id: os + attributes: + label: Windows version + placeholder: Windows 11 24H2 x64 (26100.xxxx) + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behaviour + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behaviour + description: Include the exact exception, or the CheatEngineFailure kind and operation name. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Redacted logs + description: >- + Plugin log lines around the failure. Remove paths, addresses and memory contents you do not want + public. + render: text + - type: checkboxes + id: confirmations + attributes: + label: Confirmations + options: + - label: I removed user names, private paths and target-memory contents from this report. + required: true + - label: I attached no Cheat Engine binaries, target binaries, authorization manifests or raw DebugView dumps. + required: true + - label: This is not a security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c7a6f26 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/CheatEngineNet/CheatEngine.Client/security/advisories/new + about: Private vulnerability reporting. Never open a public issue for a vulnerability. + - name: CheatEngine.SDK problems (bootstrap, native bridge, Lua bindings, ABI) + url: https://github.com/CheatEngineNet/CheatEngine.SDK/issues/new/choose + about: Low-level SDK behaviour belongs to the CheatEngine.SDK repository. + - name: Cheat Engine itself + url: https://github.com/cheat-engine/cheat-engine/issues + about: Problems in Cheat Engine are reported upstream. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f1c418b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Propose a new Client capability or an improvement to an existing one. +title: "Feature: " +labels: [ enhancement ] +body: + - type: markdown + attributes: + value: | + CheatEngine.SDK owns native bindings, Lua globals and native resource ownership; CheatEngine.Client composes + them. A request that needs a new Cheat Engine primitive usually starts in the CheatEngine.SDK repository. + Security problems go through private vulnerability reporting (see SECURITY.md), never through an issue. + - type: textarea + id: problem + attributes: + label: Problem + description: What do you want to do, and why is it hard today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed API or behaviour + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: sdk-dependency + attributes: + label: Does it need something CheatEngine.SDK does not expose? + options: + - "No" + - "Yes" + - I do not know + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1a824b8..b5d1863 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,11 @@ Closes + + ## Scope and architectural ownership Describe resulting behavior, affected contracts, and exclusions. SDK owns CE integration; Client owns workflows and policy. @@ -12,22 +17,43 @@ Link upstream prerequisites without closing them. Identify the SDK package conta ## Validation actually performed -| Check | Command / profile | Actual result | Evidence | +| Check | Command, run link or artifact | Qualification level and Q-IDs | Actual result | |---|---|---|---| -| Unit / fixture | | Not executed | | -| Packed consumer | | Not executed | | -| Live host | | Not executed | | -| AOT publication | | Not executed | | +| CI / Gate | | | Pending | +| Unit / fixture | | | Not executed | +| Packed consumer | | | Not executed | +| Live host | | | Not executed | +| AOT publication | | | Not executed | + +Qualification levels: C0 static contract · C1 managed tests or doubles · C2 native fixture · C3 exact Cheat Engine host +with a loaded plugin · C4 several components (two plugins, a target switch). A C1 or C2 result, a CI run or a Native AOT +publication is never Cheat Engine host qualification. + +## API and compatibility + +- Public API: +- 1.x compatibility: +- Experimental APIs: +- Behavior, ownership, lifetime, cleanup, cancellation and partial effects: +- CheatEngine.SDK pin and lock files: +- Migration: -## Compatibility, lifetime and partial effects +## Evidence -Explain public API or behavior changes, ownership, target switches, cleanup, cancellation and migration. + ## Documentation and review checklist - [ ] Scope is focused; existing repository style and contribution rules are preserved. - [ ] Relevant regression evidence is attached; pending gates remain explicit. - [ ] No raw state/owner escapes the normal Client boundary. -- [ ] Capability and artifact claims match actual results. +- [ ] Capability and artifact claims match actual results and state their qualification level. +- [ ] `CHANGELOG.md` has an entry under `[Unreleased]`, or nothing consumer-visible changed and the description contains the opt-out marker `` on its own line. +- [ ] The CheatEngine.SDK pin is unchanged or was moved by hand in `eng/CheatEngineSdk.props` following its documented bump procedure; lock files were regenerated with `dotnet restore --force-evaluate`, never edited by hand. +- [ ] Every new architecture ratchet exception names the missing CheatEngine.SDK primitive, or none was added. +- [ ] A pull request that contains a commit listed in `.git-blame-ignore-revs` is merged with a merge commit, never squashed or rebased. - [ ] Documentation and release impact are recorded. - [ ] No automatic merge, release, protection change or unsupported capability activation is requested. diff --git a/.github/actions/setup-dotnet/action.yml b/.github/actions/setup-dotnet/action.yml new file mode 100644 index 0000000..2e364fb --- /dev/null +++ b/.github/actions/setup-dotnet/action.yml @@ -0,0 +1,51 @@ +name: Setup .NET +description: >- + Installs the exact .NET SDK pinned by global.json, quiets the CLI and restores the listed solutions or projects in + locked mode. + +inputs: + restore: + description: >- + Newline-separated solutions or projects, each restored with 'dotnet restore --locked-mode'. Empty skips + the restore. + required: false + default: '' + cache: + description: >- + Cache the NuGet global-packages folder, keyed on every packages.lock.json. Keep 'false' in every workflow a + release, Sonar or CodeQL run can reach (a poisoned cache would feed the published packages). + required: false + default: 'false' + +runs: + using: composite + steps: + # global.json sets rollForward: disable, and runner images do not ship the pinned 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: | + 'DOTNET_NOLOGO=1', 'DOTNET_CLI_TELEMETRY_OPTOUT=1', 'MSBUILDDISABLENODEREUSE=1' | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Restore (locked) + if: inputs.restore != '' + shell: pwsh + env: + 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. The committed packages.lock.json files do not match the project graph: regenerate them with 'dotnet restore --force-evaluate' (Windows, the .NET SDK of global.json) and commit the result." + } + } diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 73fd4ad..3a5c84f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,21 +1,85 @@ +# Dependabot version updates. Options: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference +# Dependabot reads this file from the default branch only. +# +# - Cooldown: a new release waits at least 7 days (zizmor dependabot-cooldown threshold), NuGet majors 30 days. +# Security updates (repository setting) are not delayed by the cooldown. +# - Lock files: Dependabot does not regenerate every packages.lock.json (CPM + ProjectReference graphs, the +# SDK-implicit ILLink/ILCompiler entries). When the "Lock files" CI job fails on a Dependabot pull request, check the +# branch out, run 'dotnet restore --force-evaluate' for each affected project, commit +# "Regenerate lock files after " and push; Dependabot then stops rebasing that pull request. A dotnet-sdk +# update always needs regenerated lock files (global.json uses rollForward: disable) and an sdk.errorMessage naming +# the new version, moved by hand in the same commit. +# - No commit-message prefix: a prefix produces "deps: ..." subjects, which the pull request title convention forbids. +# Dependabot pull requests are exempt from that convention; rename "chore(deps): ..." squash subjects when merging. version: 2 updates: - - package-ecosystem: github-actions + # Every csproj, Directory.Packages.props, the Coexistence fixture props and .config/dotnet-tools.json. + - package-ecosystem: nuget directory: / schedule: interval: weekly day: monday open-pull-requests-limit: 5 + labels: [ dependencies, packaging ] + cooldown: + default-days: 7 + semver-major-days: 30 groups: - github-actions: - patterns: ["*"] + # CheatEngine.SDK stays out of both groups: its update, version or security, moves the reviewed pin + # (eng/CheatEngineSdk.props bump procedure: identity literals, every lock file, prose) and the lower bound of the + # SDK range the Client packages publish, so it gets a pull request of its own instead of holding the other + # updates back. + nuget-minor-and-patch: + applies-to: version-updates + patterns: [ '*' ] + exclude-patterns: [ CheatEngine.SDK ] + update-types: [ minor, patch ] + nuget-security: + applies-to: security-updates + patterns: [ '*' ] + exclude-patterns: [ CheatEngine.SDK ] + ignore: + # The Client stays on CheatEngine.SDK 2.x: range [2.0.0,3.0.0) (CHEATENGINECLIENT9016), so every major + # update (3.0.0 and later) is ignored. This covers the CPM entry and the Coexistence fixtures' + # CoexistenceSdkPackageVersion property. Another major is a deliberate maintainer migration + # (eng/CheatEngineSdk.props), never a dependency bump. + - dependency-name: CheatEngine.SDK + update-types: [ 'version-update:semver-major' ] + # Roslyn moves only with the Lua generator's compiler floor (CHEATENGINECLIENT9020). + - dependency-name: Microsoft.CodeAnalysis.CSharp + - dependency-name: Microsoft.CodeAnalysis.Analyzers + # SDK-implicit packages recorded in 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: nuget - directory: / + # Workflows and the composite actions under .github/actions. + - package-ecosystem: github-actions + directories: + - / + - /.github/actions/* schedule: interval: weekly day: monday open-pull-requests-limit: 5 + labels: [ dependencies, ci ] + cooldown: + default-days: 7 # github-actions supports default-days only groups: - dotnet: - patterns: ["*"] + actions-minor-and-patch: + update-types: [ minor, patch ] + + # global.json sdk.version (version updates only; the ecosystem has no security updates). + - package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: [ dependencies, ci ] + cooldown: + default-days: 7 + ignore: + # A new .NET major is a planned migration, not a dependency bump. + - dependency-name: '*' + update-types: [ 'version-update:semver-major' ] diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml new file mode 100644 index 0000000..4363bbe --- /dev/null +++ b/.github/dependency-review-config.yml @@ -0,0 +1,44 @@ +# Configuration of actions/dependency-review-action (ci.yml, job "Dependency review", pull requests only). Option names +# come from the action's action.yml at the pinned v5.0.0; every option can live in this file. + +# Block a pull request that adds or upgrades to a dependency with a known advisory of moderate severity or above, in +# shipped code and in build or test tooling alike. +fail-on-severity: moderate +fail-on-scopes: + - runtime + - development + +# Licenses the Client may depend on (SPDX identifiers). +allow-licenses: + - MIT + - Apache-2.0 + - BSD-2-Clause + - BSD-3-Clause + - ISC + - 0BSD + - Unlicense + - MS-PL + +# Packages whose nuspec carries a license file or a license URL instead of an SPDX expression, so the dependency graph +# cannot resolve their license. Each was checked on nuget.org for the locked version: +# CommandLineParser 2.9.1 License.md, MIT text (BenchmarkDotNet dependency) +# Microsoft.DotNet.PlatformAbstractions 3.1.6 LICENSE.TXT, MIT text (BenchmarkDotNet dependency) +# Microsoft.NETCore.Platforms 1.1.0 .NET Library license URL (legacy Microsoft package) +# Microsoft.Testing.Extensions.CodeCoverage 18.11.2 License.txt, Microsoft free-to-use license (coverage collector) +# NETStandard.Library 2.0.3 license URL of dotnet/standard, MIT (legacy Microsoft package) +# dotnet-coverage 18.11.2 license file, same license as the collector (.config/dotnet-tools.json) +allow-dependencies-licenses: + - pkg:nuget/CommandLineParser + - pkg:nuget/Microsoft.DotNet.PlatformAbstractions + - pkg:nuget/Microsoft.NETCore.Platforms + - pkg:nuget/Microsoft.Testing.Extensions.CodeCoverage + - pkg:nuget/NETStandard.Library + - pkg:nuget/dotnet-coverage + +# The dependency graph can lag behind a fresh push; wait for the head snapshot instead of reviewing a stale one. +retry-on-snapshot-warnings: true +retry-on-snapshot-warnings-timeout: 120 + +# Show OpenSSF Scorecard data for changed dependencies and warn below level 3; informational, never blocking. +show-openssf-scorecard: true +warn-on-openssf-scorecard-level: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ecbc16..68ff955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,42 @@ -name: Client validation +name: CI + +# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Callers use the job id `ci` named `CI`, so +# the only required check is "CI / Gate". +# +# build-test (Debug, Release) ─► sonar ─┐ +# aot ──────────────────────────────────┤ +# lint ─────────────────────────────────┤ +# format ───────────────────────────────┼─► gate +# dependency-review ────────────────────┤ +# lock-files ───────────────────────────┘ +# +# The Release leg packs before it tests: the package-consumption tests install the very files CI uploads. Jobs exchange +# artifacts instead of rebuilding (names reserved, nothing else is uploaded): +# nuget-packages build-test (Release) → sonar, release.yml +# coverage build-test (Debug) → sonar +# test-results- build-test → humans +# test-dumps-, binlogs-* on failure only → humans +# +# No job here uses a NuGet cache: release.yml reaches every job. on: workflow_call: inputs: - collect_sonar_coverage: - description: Produce Microsoft XML coverage reports consumed by the guarded Sonar job. - required: false + sonar: + description: Run the SonarQube Cloud analysis. Fork pull requests and Dependabot always skip it (SONAR_EXPECTED). type: boolean default: false + package-version: + description: When set (release), the Release leg must produce exactly ..nupkg for every Client package. + type: string + default: '' + package-retention-days: + description: Days to keep the nuget-packages artifact. + type: number + default: 7 secrets: SONAR_TOKEN: - description: SonarQube Cloud token for protected same-repository analysis. + description: SonarQube Cloud analysis token. Needed only when sonar is true. required: false permissions: @@ -20,281 +46,414 @@ defaults: run: shell: pwsh -env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - MSBUILDDISABLENODEREUSE: true - jobs: - validate: - name: Validate packages, template, and AOT graph - runs-on: windows-latest - timeout-minutes: 35 - + build-test: + name: Build and test (${{ matrix.configuration }}) + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + configuration: [ Debug, Release ] + env: + CONFIGURATION: ${{ matrix.configuration }} + RESULTS: artifacts/test-results/${{ matrix.configuration }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 0 # the package version comes from tags and history persist-credentials: false - - name: Install pinned .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + - name: Setup .NET + uses: ./.github/actions/setup-dotnet with: - global-json-file: global.json - cache: true - cache-dependency-path: | - **/packages.lock.json - Directory.Packages.props + restore: CheatEngine.Client.slnx - - name: Restore locked dependency graph + # No --warnaserror: it would also promote the NuGet audit warnings replayed at build (NU1901/NU1902) and override + # the audit policy. TreatWarningsAsErrors (Directory.Build.props) keeps compiler and analyzer strictness. + - name: Build run: | - dotnet restore CheatEngine.Client.slnx --locked-mode + dotnet build CheatEngine.Client.slnx -c $env:CONFIGURATION --no-restore "-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog" if ($LASTEXITCODE -ne 0) { - throw "dotnet restore failed with exit code $LASTEXITCODE." + throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." } - - name: Build Release + # Every .nupkg must embed its SPDX SBOM (_manifest/spdx_2.2/manifest.spdx.json): the pack generates it + # unconditionally (eng/Shipping.props, eng/Templates.props) and release.yml attests it. The seven ids produce + # exactly one nupkg each and a snupkg for every one but the two with no build output (Client, Templates); when a + # release names the exact version, every file must be named "..nupkg" / ".snupkg". + - name: Pack + id: pack + if: matrix.configuration == 'Release' + env: + PACKAGE_VERSION: ${{ inputs.package-version }} run: | - dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --warnaserror + $ErrorActionPreference = 'Stop' + dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog if ($LASTEXITCODE -ne 0) { - throw "dotnet build failed with exit code $LASTEXITCODE." + throw "Client package creation failed with exit code $LASTEXITCODE." } - - name: Pack and validate public package APIs - run: | - dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore --output artifacts/packages - if ($LASTEXITCODE -ne 0) { - throw "dotnet pack failed with exit code $LASTEXITCODE." + # The seven ids, each declared once with whether it ships a symbol package (release.yml lists the same ids in + # PACKAGE_IDS). + $shipsSymbols = [ordered]@{ + 'CheatEngine.Client' = $false + 'CheatEngine.Client.Abstractions' = $true + 'CheatEngine.Client.Core' = $true + 'CheatEngine.Client.Extensions.DependencyInjection' = $true + 'CheatEngine.Client.Fluent' = $true + 'CheatEngine.Client.Hosting' = $true + 'CheatEngine.Client.Templates' = $false } - - - name: Run unit tests through Microsoft Testing Platform - env: - COLLECT_SONAR_COVERAGE: ${{ inputs.collect_sonar_coverage }} - CHEATENGINE_CLIENT_PACKAGE_SOURCE: ${{ github.workspace }}/artifacts/packages - run: | - $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File | Sort-Object Name) - if ($projects.Count -eq 0) { - throw 'No *.Tests.csproj project found under tests.' + $ids = @($shipsSymbols.Keys) + $noSymbols = @($ids | Where-Object { -not $shipsSymbols[$_] }) + $nupkgs = @(Get-ChildItem artifacts/nuget -Filter '*.nupkg') + $snupkgs = @(Get-ChildItem artifacts/nuget -Filter '*.snupkg') + if ($nupkgs.Count -ne $ids.Count -or $snupkgs.Count -ne ($ids.Count - $noSymbols.Count)) { + throw "Expected $($ids.Count) nupkg and $($ids.Count - $noSymbols.Count) snupkg in artifacts/nuget, found $($nupkgs.Count) nupkg and $($snupkgs.Count) snupkg." } - foreach ($project in $projects) { - $results = Join-Path 'artifacts/test-results' $project.BaseName - $options = @( - '--project', $project.FullName, - '--configuration', 'Release', - '--no-build', '--no-restore', - '--report-trx', - '--results-directory', $results, - '--fail-skips', 'on' - ) - - if ($env:COLLECT_SONAR_COVERAGE -eq 'true') { - $coverage = [IO.Path]::GetFullPath((Join-Path (Join-Path 'artifacts/sonar-test-results' $project.BaseName) 'coverage.xml')) - $options += @( - '--coverage', - '--coverage-output', $coverage, - '--coverage-output-format', 'xml' - ) - } - - dotnet test @options - if ($LASTEXITCODE -ne 0) { - throw "Microsoft Testing Platform failed for '$($project.FullName)' with exit code $LASTEXITCODE." - } - } - - - name: Verify Sonar coverage reports - if: ${{ inputs.collect_sonar_coverage && !cancelled() }} - run: | - $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File) - $reports = @($projects | ForEach-Object { - Join-Path (Join-Path 'artifacts/sonar-test-results' $_.BaseName) 'coverage.xml' - }) - $missingReports = @($reports | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }) - if ($missingReports.Count -gt 0) { - throw "Microsoft Testing Platform did not produce XML coverage report(s): $($missingReports -join ', ')." - } - - foreach ($report in $reports) { - if ((Get-Item -LiteralPath $report).Length -eq 0) { - throw "Coverage report '$report' is empty." + Add-Type -AssemblyName System.IO.Compression.FileSystem + foreach ($id in $ids) { + $expectedName = if ($env:PACKAGE_VERSION) { "$id.$env:PACKAGE_VERSION.nupkg" } else { $null } + $nupkg = if ($expectedName) { Join-Path artifacts/nuget $expectedName } else { ($nupkgs | Where-Object BaseName -Match "^$([regex]::Escape($id))\.\d") | Select-Object -First 1 -ExpandProperty FullName } + if (-not $nupkg -or -not (Test-Path -LiteralPath $nupkg -PathType Leaf)) { + throw "artifacts/nuget has no package for $id$(if ($expectedName) { " named $expectedName" })." } + $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $nupkg).Path) try { - $coverage = [xml](Get-Content -LiteralPath $report -Raw) - } - catch { - throw "Coverage report '$report' is not valid XML. $($_.Exception.Message)" + if ($null -eq $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json')) { + throw "$nupkg has no embedded SBOM at _manifest/spdx_2.2/manifest.spdx.json." + } } - - if ($null -eq $coverage.DocumentElement) { - throw "Coverage report '$report' has no XML document element." + finally { + $archive.Dispose() } } - Write-Host "Verified $($reports.Count) non-empty XML coverage report(s)." + Write-Output "Verified $($nupkgs.Count) nupkg and $($snupkgs.Count) snupkg, each embedding its SBOM." - - name: Summarize test results - if: ${{ !cancelled() }} + # The Release Test step consumes these exact packages (CHEATENGINE_CLIENT_PACKAGE_SOURCE), never a package it + # packs itself: a self-pack would test different bits than the ones this run uploads. + $absolute = (Resolve-Path artifacts/nuget).Path + "package-source=$absolute" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # The solution build compiles the benchmarks; listing them proves discovery. Benchmarks never run in the Gate. + - name: Benchmarks discovery + if: matrix.configuration == 'Release' + run: | + $ErrorActionPreference = 'Stop' + $listed = @(dotnet run --project tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj -c Release --no-build -- --list flat --artifacts artifacts/benchmarks) + if ($LASTEXITCODE -ne 0) { + throw "Benchmark discovery failed with exit code $LASTEXITCODE." + } + $benchmarks = @($listed | Where-Object { $_ -match '^CheatEngine\.Client\.Benchmarks\.' }) + if ($benchmarks.Count -eq 0) { + throw "The benchmark switcher listed no benchmark:`n$($listed -join "`n")" + } + "Discovered $($benchmarks.Count) benchmarks." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + # One parallel run over every tests/**/*.Tests module. A skip fails the run (--fail-skips on). Debug collects one + # coverage report per module and leaves the package-consumption tests to Release by trait filter, never Skip. + # Release hands those tests the packages the Pack step produced. Both legs exclude the live qualification tests + # by trait: they start a sandboxed Cheat Engine and run only on a maintainer workstation that opts in. The hang + # dump fires after 15 minutes without test activity, well below the job timeout. + - name: Test + env: + PACKAGE_SOURCE: ${{ steps.pack.outputs.package-source }} run: | - $reports = @(Get-ChildItem -Path artifacts/test-results -Filter *.trx -Recurse -File -ErrorAction SilentlyContinue) - if ($reports.Count -eq 0) { - throw 'Microsoft Testing Platform did not produce a TRX test report.' + $ErrorActionPreference = 'Stop' + $options = @( + '--solution', 'CheatEngine.Client.slnx', '-c', $env:CONFIGURATION, '--no-build', '--results-directory', $env:RESULTS, + '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off', + '--hangdump', '--hangdump-timeout', '15m', '--crashdump', + '--filter-not-trait', 'Category=LiveQualification' + ) + if ($env:CONFIGURATION -eq 'Debug') { + $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=PackageConsumption' + } + else { + # Set only here: the smoke tests fail on a set-but-empty value, and the Debug leg has no packages. + if ([string]::IsNullOrEmpty($env:PACKAGE_SOURCE)) { + throw 'The Pack step exported no package-source.' + } + $env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = $env:PACKAGE_SOURCE + } + dotnet test @options + if ($LASTEXITCODE -ne 0) { + throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." } - $results = @($reports | ForEach-Object { - ([xml](Get-Content -LiteralPath $_.FullName -Raw)).TestRun.Results.UnitTestResult - } | Where-Object { $_ }) - $passed = @($results | Where-Object outcome -eq 'Passed').Count - $skipped = @($results | Where-Object outcome -eq 'NotExecuted').Count - $failed = @($results | Where-Object { $_.outcome -notin 'Passed', 'NotExecuted' }) - - @( - '### Test results', - '', - '| Total | Passed | Failed | Skipped |', - '| ---: | ---: | ---: | ---: |', - "| $($results.Count) | $passed | $($failed.Count) | $skipped |" - ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + - name: Upload packages + if: matrix.configuration == 'Release' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nuget-packages + path: | + artifacts/nuget/*.nupkg + artifacts/nuget/*.snupkg + if-no-files-found: error + retention-days: ${{ inputs.package-retention-days }} + + - name: Upload coverage + if: matrix.configuration == 'Debug' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage + path: ${{ env.RESULTS }}/*.xml + if-no-files-found: error + retention-days: 7 - name: Upload test results if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results - path: artifacts/test-results/**/*.trx + name: test-results-${{ matrix.configuration }} + path: ${{ env.RESULTS }}/*.trx if-no-files-found: warn - retention-days: 14 + retention-days: 7 - - name: Upload Sonar coverage reports - if: ${{ inputs.collect_sonar_coverage && !cancelled() }} + # Crash dumps, hang dumps and the test sequence logs that name the tests running at the time. + - name: Upload test dumps + if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: sonar-coverage - path: artifacts/sonar-test-results/*/coverage.xml - if-no-files-found: error - retention-days: 14 + name: test-dumps-${{ matrix.configuration }} + path: | + ${{ env.RESULTS }}/**/*.dmp + ${{ env.RESULTS }}/**/*.log + if-no-files-found: ignore + retention-days: 5 - - name: Upload package artifacts + - name: Upload binary logs + if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: nuget-packages - path: | - artifacts/packages/*.nupkg - artifacts/packages/*.snupkg - if-no-files-found: error - retention-days: 14 + name: binlogs-build-test-${{ matrix.configuration }} + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + # A trim and Native AOT graph probe only: it never loads anything into Cheat Engine. + aot: + name: Native AOT publication probe + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the package version comes from tags and history + persist-credentials: false - - name: Publish Native AOT reference probe + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj + + # The project pins its RuntimeIdentifier (win-x64), so publish needs no --runtime. + - name: Publish and run Native AOT probe run: | - dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj --configuration Release --runtime win-x64 --no-restore --output ./artifacts/aot-probe + $ErrorActionPreference = 'Stop' + $output = Join-Path $PWD 'artifacts/aot-probe' + dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-publish.binlog if ($LASTEXITCODE -ne 0) { - throw "dotnet publish failed with exit code $LASTEXITCODE." + throw "Native AOT probe publish failed with exit code $LASTEXITCODE." } - - - name: Run Native AOT reference probe - run: | - ./artifacts/aot-probe/CheatEngine.Client.AotProbe.exe + $probe = Join-Path $output 'CheatEngine.Client.AotProbe.exe' + if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { + throw "Native AOT publish did not produce '$probe'." + } + & $probe if ($LASTEXITCODE -ne 0) { - throw "The Native AOT reference probe failed with exit code $LASTEXITCODE." + throw "Native AOT probe exited with code $LASTEXITCODE." } - - name: Upload Native AOT probe - if: ${{ !cancelled() }} + - name: Upload binary logs + if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: native-aot-probe - path: artifacts/aot-probe - if-no-files-found: warn - retention-days: 14 + name: binlogs-aot + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + # Pull requests fail on the quality gate; main only reports it. SONAR_EXPECTED below must stay identical to the gate's. sonar: name: Sonar - needs: validate - if: ${{ inputs.collect_sonar_coverage }} + 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) uses: ./.github/workflows/sonar.yml with: - ci_based_analysis: true + wait-quality-gate: ${{ github.event_name != 'push' }} secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - dependency-review: - name: Dependency review - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }} - runs-on: ubuntu-latest + lint: + name: Lint + runs-on: ubuntu-24.04 timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Review dependency changes - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 - with: - fail-on-severity: high - - lint-workflows: - name: Lint workflows - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }} - runs-on: windows-latest - timeout-minutes: 5 env: ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9 + 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 run: | $ErrorActionPreference = 'Stop' - $archive = Join-Path $env:RUNNER_TEMP 'actionlint.zip' - $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_windows_amd64.zip" + $archive = Join-Path $env:RUNNER_TEMP 'actionlint.tar.gz' + $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_linux_amd64.tar.gz" Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3 -RetryIntervalSec 5 $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $env:ACTIONLINT_SHA256) { throw "actionlint $env:ACTIONLINT_VERSION has SHA-256 $actual, expected $env:ACTIONLINT_SHA256." } - $destination = Join-Path $env:RUNNER_TEMP 'actionlint' - Expand-Archive -LiteralPath $archive -DestinationPath $destination - & (Join-Path $destination 'actionlint.exe') -color + tar -xzf $archive -C $env:RUNNER_TEMP actionlint + if ($LASTEXITCODE -ne 0) { + throw "Extracting actionlint failed with exit code $LASTEXITCODE." + } + & (Join-Path $env:RUNNER_TEMP 'actionlint') -color if ($LASTEXITCODE -ne 0) { throw "actionlint failed with exit code $LASTEXITCODE." } + # Offline audits only, so the result depends on the commit alone; the online audits run in zizmor-online.yml. + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: false + advanced-security: false + config: .github/zizmor.yml + + 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 + + # Whitespace needs no restore or MSBuild workspace, and the CRLF checkout (.gitattributes) matches .editorconfig. + # Style rules are enforced by the build (EnforceCodeStyleInBuild). + - 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). Fix it with: dotnet format whitespace --folder" + } + + # Always runs, so the Gate never has to accept a skip: the review itself runs on pull requests only. + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + 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 + + - name: Nothing to review + if: github.event_name != 'pull_request' + env: + EVENT: ${{ github.event_name }} + run: Write-Host "::notice title=Dependency review::Dependency review compares a pull request with its base; a $env:EVENT run has nothing to compare." + + 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 + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + # Windows only: Native AOT lock sections record host-RID ILCompiler packages. A locked restore fails outright + # when a committed packages.lock.json no longer matches its project graph. + - name: Verify lock files + run: | + dotnet restore CheatEngine.Client.slnx --locked-mode + if ($LASTEXITCODE -ne 0) { + throw "Locked restore failed with exit code $LASTEXITCODE. The committed packages.lock.json files do not match the project graph: regenerate the affected ones with 'dotnet restore --force-evaluate' and commit the result." + } + gate: name: Gate - if: ${{ always() }} - needs: [validate, sonar, dependency-review, lint-workflows] - runs-on: windows-latest + # always(): a failed or cancelled job must turn the required check red instead of skipping it. + if: always() + needs: [ build-test, aot, sonar, lint, format, dependency-review, lock-files ] + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} - steps: - - name: Check required results + - name: Check results env: - VALIDATE_RESULT: ${{ needs.validate.result }} - SONAR_RESULT: ${{ needs.sonar.result }} - SONAR_REQUIRED: ${{ inputs.collect_sonar_coverage }} - DEPENDENCY_RESULT: ${{ needs.dependency-review.result }} - LINT_RESULT: ${{ needs.lint-workflows.result }} + 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: | - $results = [ordered]@{ - validate = $env:VALIDATE_RESULT - sonar = $env:SONAR_RESULT - 'dependency-review' = $env:DEPENDENCY_RESULT - 'lint-workflows' = $env:LINT_RESULT + # Every job must succeed. Only sonar has a required result that depends on the event: success when + # SONAR_EXPECTED is true, skipped otherwise (fork, Dependabot, release call). A sonar run that was not + # expected means its condition drifted from SONAR_EXPECTED, which fails too. + $ErrorActionPreference = 'Stop' + $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable + $sonarExpected = $env:SONAR_EXPECTED -eq 'true' + $failed = [Collections.Generic.List[string]]::new() + $rows = foreach ($job in @($needs.Keys | Sort-Object)) { + $result = $needs[$job].result + $required = 'success' + $reason = 'every job must succeed' + if ($job -eq 'sonar') { + if ($sonarExpected) { + $reason = 'SONAR_EXPECTED=true' + if ($result -eq 'skipped' -and $needs['build-test'].result -ne 'success') { + $reason = 'SONAR_EXPECTED=true; skipped because build-test did not succeed' + } + } + else { + $required = 'skipped' + $reason = "SONAR_EXPECTED=false ($env:EVENT run: fork, Dependabot or release call)" + } + } + if ($result -ne $required) { + $failed.Add("$job ($result, required $required)") + } + "| $job | $result | $required | $reason |" } - $rows = $results.GetEnumerator() | ForEach-Object { "| $($_.Key) | $($_.Value) |" } - '| Job | Result |', '| --- | --- |', $rows | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - $failed = @($results.GetEnumerator() | Where-Object { - $_.Key -eq 'validate' -and $_.Value -ne 'success' -or - $_.Key -eq 'sonar' -and $env:SONAR_REQUIRED -eq 'true' -and $_.Value -ne 'success' -or - $_.Key -in 'dependency-review', 'lint-workflows' -and $_.Value -ne 'success' - } | ForEach-Object Key) + @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 if ($failed.Count -gt 0) { - Write-Host "::error::Required job(s) did not succeed: $($failed -join ', ')." + Write-Host "::error::Not as required: $($failed -join ', ')." exit 1 } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0baebc7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,107 @@ +# Advisory CodeQL code scanning. Not part of CI / Gate: findings appear in the Security tab and as PR annotations. +# C# is analysed from a manual, traced Release build of the shipped product graph (src/CheatEngine.Client builds every +# shipped assembly and the Lua source generator), so source-generator output is analysed; `build-mode: none` would skip +# generated code. GitHub Actions workflows are analysed without a build. The Client detects no other language. +# Keep the repository's code-scanning default setup OFF: GitHub rejects advanced uploads while it is enabled. +# No dependency or TRAP cache (shared-contracts §1.5: no cache on any path reachable by release, sonar or codeql). +name: CodeQL + +on: + pull_request: + push: + branches: [ main ] + schedule: + - cron: '23 4 * * 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 C# + runs-on: windows-2025 + timeout-minutes: 45 + permissions: + contents: read + security-events: write # upload the SARIF results + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer computes the package version from the tag history + persist-credentials: false + + - name: Set up .NET and restore the product graph (locked) + uses: ./.github/actions/setup-dotnet + with: + restore: src/CheatEngine.Client/CheatEngine.Client.csproj + cache: 'false' + + - 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 process: no incremental skip, no compiler server, no build server. + # https://learn.microsoft.com/dotnet/core/tools/dotnet-build#options + - name: Build the shipped product graph + run: | + $ErrorActionPreference = 'Stop' + dotnet build src/CheatEngine.Client/CheatEngine.Client.csproj --configuration Release --no-restore ` + --no-incremental --disable-build-servers -p:UseSharedCompilation=false ` + -bl:artifacts/logs/codeql-csharp.binlog + if ($LASTEXITCODE -ne 0) { + throw "dotnet build failed with exit code $LASTEXITCODE." + } + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:csharp + + - name: Upload binlog + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-codeql-csharp + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + actions: + name: Analyze GitHub Actions + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + security-events: write # upload the SARIF results + 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 + + - 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 0000000..08d37fd --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,57 @@ +# Advisory CI-side dependency submission: automatic NuGet submission cannot be enabled for this organization, and the +# dependency graph detects no NuGet package from the CPM + lock-file layout on its own. GitHub's own Component +# Detection dependency submission action (https://github.com/advanced-security/component-detection-dependency-submission-action) +# scans the locked restore and submits the snapshot in one step. +# 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 never run it (contents: write stays out of reach). +name: Dependency submission + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +# Only the newest snapshot of a pull request matters; a push to main is never cancelled, so a quick second push does not +# drop the base snapshot of the first commit. +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: + submit: + name: Submit NuGet dependency snapshot + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Windows: the AOT probe's lock file holds win-x64 sections, so a locked restore fails on Linux. + runs-on: windows-2025 + timeout-minutes: 20 + permissions: + contents: write # POST /repos/{owner}/{repo}/dependency-graph/snapshots + 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 + + - name: Set up .NET and restore (locked) + uses: ./.github/actions/setup-dotnet + with: + restore: CheatEngine.Client.slnx + cache: 'false' + + - name: Detect and submit NuGet dependencies + uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6 + with: + detectorsCategories: NuGet + correlator: ${{ github.workflow }}_nuget + snapshot-sha: ${{ github.event.pull_request.head.sha || github.sha }} + snapshot-ref: ${{ github.ref }} diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 26dfcc8..7b91df7 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -2,23 +2,24 @@ name: Main CI on: push: - branches: [main] - merge_group: - types: [checks_requested] + branches: [ main ] workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false +# No concurrency group: every main commit keeps its own complete run (a pending run in a group would be replaced by the +# next one). permissions: contents: read +defaults: + run: + shell: pwsh + jobs: ci: name: CI uses: ./.github/workflows/ci.yml with: - collect_sonar_coverage: ${{ vars.SONAR_CI_ENABLED == 'true' && github.ref == 'refs/heads/main' }} + sonar: true # ci.yml reports the quality gate on push and waits for it on manual runs secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 5210a23..18a6b3a 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -2,8 +2,9 @@ name: Pull request CI on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [ opened, synchronize, reopened, ready_for_review ] +# A new push supersedes the previous run of the same pull request. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -11,16 +12,17 @@ concurrency: permissions: contents: read +defaults: + run: + shell: pwsh + jobs: ci: name: CI - if: github.event.pull_request.draft == false + # Drafts do not run CI. "Ready for review" starts it; until then the required CI / Gate check stays pending. + if: ${{ !github.event.pull_request.draft }} uses: ./.github/workflows/ci.yml with: - collect_sonar_coverage: >- - ${{ vars.SONAR_CI_ENABLED == 'true' - && github.event.pull_request.draft == false - && github.event.pull_request.head.repo.full_name == github.repository - && github.event.pull_request.user.login != 'dependabot[bot]' }} + sonar: true # ci.yml still skips Sonar 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 new file mode 100644 index 0000000..3f2b6a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,988 @@ +name: Release + +run-name: Release ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' (dry run)' || '' }} + +# verify ─► ci ─► stage ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release +# +# The seven packages that ci builds, tests and uploads as nuget-packages are the exact files that are staged with their +# SBOMs and SHA256SUMS, attested, attached to the draft release and pushed to nuget.org. Nothing is published before a +# draft release carries every asset, and the release itself is published only after nuget.org serves the attested +# packages, so the flow works with immutable releases. Only a tag push of this repository releases: a workflow_dispatch +# run, even one started from a tag, is a dry run that verifies, builds and stages the SBOMs and SHA256SUMS with a +# read-only token, and never attests, drafts or publishes. +# No job restores from or saves to a NuGet cache. RELEASING.md describes the procedure and every check. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +# The seven package ids, each declared once, in dependency order: every package comes after the Client packages it +# depends on, the order publish pushes them in. Every list of packages in this workflow is derived from this one. +env: + PACKAGE_IDS: >- + CheatEngine.Client.Abstractions + CheatEngine.Client.Fluent + CheatEngine.Client.Core + CheatEngine.Client.Extensions.DependencyInjection + CheatEngine.Client.Hosting + CheatEngine.Client + CheatEngine.Client.Templates + +jobs: + verify: + name: Verify tag + runs-on: windows-2025 + timeout-minutes: 10 + outputs: + version: ${{ steps.tag.outputs.version }} + prerelease: ${{ steps.tag.outputs.prerelease }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + with: + cache: 'false' + + # Any event but a push is a dry run with empty outputs. A tag push needs a SemVer tag on the first-parent history + # of main, and none of the seven ids may already have that version on nuget.org (a version can never be + # replaced, and a full re-run after a publication would build different bytes for an immutable version). + - name: Verify tag + id: tag + env: + EVENT_NAME: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:EVENT_NAME -ne 'push') { + Write-Output "::notice::Dry run ($env:EVENT_NAME on $env:REF_TYPE $env:REF_NAME): the run builds and tests, and never attests, drafts a release or publishes." + 'version=', 'prerelease=' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + exit 0 + } + if ($env:REF_TYPE -ne 'tag') { + throw "A release run started by a push must come from a v*.*.* tag; $env:REF_TYPE $env:REF_NAME is not a tag." + } + + $semver = '^v(?(?: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-]*))*)?)$' + if ($env:REF_NAME -cnotmatch $semver) { + throw "Tag $env:REF_NAME is not a release tag. Use v.. with an optional SemVer prerelease such as -rc.1, and no build metadata." + } + $version = $Matches['version'] + + # Only a commit on the first-parent line of main, the merged head, may be released: a squash merge gives the + # pull request new commits, so a tag on a branch commit would release something main never contained. + $commit = git rev-parse --verify "refs/tags/$env:REF_NAME^{commit}" + if ($LASTEXITCODE -ne 0) { + throw "Tag $env:REF_NAME does not resolve to a commit (exit code $LASTEXITCODE)." + } + $landed = @(git rev-list --first-parent refs/remotes/origin/main) + if ($LASTEXITCODE -ne 0) { + throw "The checkout has no refs/remotes/origin/main to compare the tag with (exit code $LASTEXITCODE); check out with fetch-depth 0." + } + if ($landed -notcontains $commit) { + throw "Tag $env:REF_NAME points to $commit, which is not on the first-parent history of main. Tag the merged head of main." + } + + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + foreach ($id in $packageIds) { + $uri = "https://api.nuget.org/v3-flatcontainer/$($id.ToLowerInvariant())/index.json" + $index = Invoke-RestMethod -Uri $uri -SkipHttpErrorCheck -StatusCodeVariable status -MaximumRetryCount 3 -RetryIntervalSec 5 + if ($status -eq 200 -and (@($index.versions) -contains $version.ToLowerInvariant())) { + throw "$id $version is already on nuget.org. Re-run only the failed jobs of the original run, or tag a new version." + } + if ($status -ne 200 -and $status -ne 404) { + throw "Reading $uri returned HTTP $status." + } + } + + $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } + Write-Output "Releasing $version (prerelease: $prerelease) from $commit." + "version=$version", "prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # The GitHub release notes are the CHANGELOG.md section of the version: '## [X.Y.Z] - '. A prerelease may + # ship before its entries leave '## [Unreleased]', so it falls back to that section; a stable release may not. + - name: Extract release notes + if: steps.tag.outputs.version != '' + env: + VERSION: ${{ steps.tag.outputs.version }} + PRERELEASE: ${{ steps.tag.outputs.prerelease }} + run: | + $ErrorActionPreference = 'Stop' + $changelog = (Get-Content -LiteralPath CHANGELOG.md -Raw).Replace("`r`n", "`n") + $sections = @($env:VERSION) + if ($env:PRERELEASE -eq 'true') { + $sections += 'Unreleased' + } + + $notes = '' + foreach ($section in $sections) { + $pattern = '(?ms)^## \[' + [regex]::Escape($section) + '\][^\n]*\n(?.*?)(?=^## \[|^\[[^\]]+\]:|\z)' + $match = [regex]::Match($changelog, $pattern) + if ($match.Success -and $match.Groups['body'].Value.Trim()) { + $notes = $match.Groups['body'].Value.Trim() + break + } + } + + if (-not $notes) { + throw "CHANGELOG.md has no non-empty section for $($sections -join ' or '). Move the release entries under '## [$env:VERSION] - ' before tagging." + } + + [string[]] $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + [System.Array]::Sort($packageIds, [System.StringComparer]::Ordinal) + $packages = $packageIds | ForEach-Object { "- [$_ $env:VERSION](https://www.nuget.org/packages/$_/$env:VERSION)" } + + New-Item -ItemType Directory -Path artifacts/release-notes -Force | Out-Null + $text = (@($notes, '', '## Packages', '') + @($packages) + @('', + 'The release assets include SHA256SUMS, the SPDX SBOM of each package and their sigstore attestation bundles. RELEASING.md explains how to verify them.')) -join "`n" + [System.IO.File]::WriteAllText('artifacts/release-notes/release-notes.md', $text + "`n", [System.Text.UTF8Encoding]::new($false)) + Write-Output "Wrote the release notes of $env:VERSION." + + - name: Upload release notes + if: steps.tag.outputs.version != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-notes + path: artifacts/release-notes/release-notes.md + if-no-files-found: error + retention-days: 90 + + # Build and test the tag commit: MinVer stamps the tag version at compile time, so a main build cannot be promoted. + # Sonar already analysed this commit on main and is never passed here. + ci: + name: CI + needs: verify + uses: ./.github/workflows/ci.yml + with: + package-version: ${{ needs.verify.outputs.version }} + package-retention-days: 90 + + # Every run, dry runs included, with a read-only token and no OIDC token: extracts the SPDX SBOM each package embeds + # and writes SHA256SUMS over the packages, the symbol packages and the SBOMs. A dry run ends here, with those files in + # the release-staging artifact; a release attests exactly the files this job staged. + stage: + name: Stage SBOMs and checksums + needs: [ verify, ci ] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-packages + path: artifacts/nuget + + # Every package embeds its SPDX 2.2 document at _manifest/spdx_2.2/manifest.spdx.json. Its exact bytes are + # extracted as ..spdx.json, after checking that it describes that package and version, so attest + # signs (actions/attest sbom-path) and the release carries the document the package holds. + - name: Stage the SBOMs and SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + + # A release names its version (verify); a dry run takes the version MinVer gave the packages. + $version = $env:VERSION + if (-not $version) { + $pattern = '^' + [regex]::Escape($packageIds[0]) + '\.(?[0-9][0-9A-Za-z.-]*)\.nupkg$' + $found = @(Get-ChildItem -LiteralPath artifacts/nuget -File | ForEach-Object { [regex]::Match($_.Name, $pattern) } | Where-Object Success) + if ($found.Count -ne 1) { + throw "nuget-packages must hold exactly one $($packageIds[0]) package, found $($found.Count)." + } + $version = $found[0].Groups['version'].Value + } + + # Exactly the seven packages of that version and symbol packages of them. + $packages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($id in $packageIds) { + [void] $packages.Add("$id.$version.nupkg") + if (-not (Test-Path -LiteralPath "artifacts/nuget/$id.$version.nupkg" -PathType Leaf)) { + throw "nuget-packages has no $id.$version.nupkg." + } + } + foreach ($file in @(Get-ChildItem -LiteralPath artifacts/nuget -File)) { + $symbols = $file.Extension -ceq '.snupkg' -and $packages.Contains("$($file.BaseName).nupkg") + if (-not $packages.Contains($file.Name) -and -not $symbols) { + throw "nuget-packages holds $($file.Name), which is neither a package nor a symbol package of the seven ids at $version." + } + } + + $staging = Join-Path $PWD 'artifacts/staging' + New-Item -ItemType Directory -Path $staging -Force | Out-Null + foreach ($id in $packageIds) { + $archive = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $PWD "artifacts/nuget/$id.$version.nupkg")) + try { + $entry = $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json') + if ($null -eq $entry) { + throw "$id.$version.nupkg has no _manifest/spdx_2.2/manifest.spdx.json." + } + $stream = $entry.Open() + try { + $memory = [System.IO.MemoryStream]::new() + $stream.CopyTo($memory) + $bytes = $memory.ToArray() + } + finally { + $stream.Dispose() + } + } + finally { + $archive.Dispose() + } + + $document = [System.Text.Encoding]::UTF8.GetString($bytes).TrimStart([char] 0xFEFF) | ConvertFrom-Json + $root = @($document.packages | Where-Object { $_.SPDXID -eq 'SPDXRef-RootPackage' }) + if ($document.spdxVersion -ne 'SPDX-2.2' -or $root.Count -ne 1 -or $root[0].name -ne $id -or $root[0].versionInfo -ne $version) { + throw "The SBOM inside $id.$version.nupkg does not describe $id $version as SPDX-2.2." + } + [System.IO.File]::WriteAllBytes((Join-Path $staging "$id.$version.spdx.json"), $bytes) + } + + # sha256sum format (' ', sorted ordinally by name, LF). + $assets = [System.Collections.Generic.SortedDictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $PWD 'artifacts/nuget'), $staging -File)) { + $assets.Add($file.Name, $file.FullName) + } + $lines = foreach ($name in $assets.Keys) { + '{0} {1}' -f (Get-FileHash -LiteralPath $assets[$name] -Algorithm SHA256).Hash.ToLowerInvariant(), $name + } + [System.IO.File]::WriteAllText((Join-Path $staging 'SHA256SUMS'), (($lines -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) + + $run = if ($env:VERSION) { "Release $version" } else { "Dry run of $version (nothing is attested, drafted or published)" } + $summary = @( + '### Staged SBOMs and checksums', + '', + "$run. The release-staging artifact holds the $($packageIds.Count) SBOMs and SHA256SUMS.", + '', + '| Asset | SHA-256 |', + '| --- | --- |' + ) + @($lines | ForEach-Object { $hash, $name = $_ -split ' ', 2; "| ``$name`` | ``$hash`` |" }) + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + - name: Upload the staged SBOMs and SHA256SUMS + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-staging + path: artifacts/staging + if-no-files-found: error + retention-days: 90 + + # attest, draft-release and publish share one guard: a tag push of this repository that verify accepted. Every later + # job needs one of them, so a dry run skips the whole release path. + attest: + name: Attest packages + needs: [ verify, ci, stage ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # sign the attestations with the workflow identity + attestations: write # store them in the repository + env: + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-packages + path: artifacts/nuget + + - name: Download the staged SBOMs and SHA256SUMS + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-staging + path: artifacts/attestations + + # This job signs exactly what stage checked and listed: every package, symbol package and SBOM has the SHA-256 + # of the staged SHA256SUMS, and nothing else is there. The staged SHA256SUMS is then removed; it is written + # again over every release asset once the attestation bundles exist. + - name: Check the staged files + run: | + $ErrorActionPreference = 'Stop' + $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in @(Get-Content -LiteralPath artifacts/attestations/SHA256SUMS)) { + if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { + throw "The staged SHA256SUMS has a malformed or repeated line: '$line'." + } + } + $files = @(Get-ChildItem artifacts/nuget, artifacts/attestations -File | Where-Object Name -cne 'SHA256SUMS') + foreach ($file in $files) { + $actual = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $sums.ContainsKey($file.Name) -or $sums[$file.Name] -cne $actual) { + throw "$($file.Name) has SHA-256 $actual, which the staged SHA256SUMS does not list for it." + } + } + if ($files.Count -ne $sums.Count) { + throw "The staged SHA256SUMS lists $($sums.Count) files, but $($files.Count) are here." + } + foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { + foreach ($name in "$id.$env:VERSION.nupkg", "$id.$env:VERSION.spdx.json") { + if (-not $sums.ContainsKey($name)) { + throw "stage did not stage $name." + } + } + } + Remove-Item -LiteralPath artifacts/attestations/SHA256SUMS + + # One provenance attestation covers the seven packages and the five symbol packages. + - name: Attest build provenance + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + artifacts/nuget/*.nupkg + artifacts/nuget/*.snupkg + + # actions/attest takes one SBOM per call, so the seven SBOM steps below are numbered: SBOM n attests the package + # at position n of PACKAGE_IDS, which therefore must name exactly seven ids. + - name: Map the SBOM attestations to the packages + id: subjects + run: | + $ErrorActionPreference = 'Stop' + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + if ($packageIds.Count -ne 7) { + throw "The seven SBOM attestation steps need exactly seven ids in PACKAGE_IDS, found $($packageIds.Count)." + } + $outputs = for ($index = 0; $index -lt $packageIds.Count; $index++) { + "package-$($index + 1)=artifacts/nuget/$($packageIds[$index]).$env:VERSION.nupkg" + "sbom-$($index + 1)=artifacts/attestations/$($packageIds[$index]).$env:VERSION.spdx.json" + } + $outputs | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Attest SBOM 1 + id: sbom-1 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-1 }} + sbom-path: ${{ steps.subjects.outputs.sbom-1 }} + + - name: Attest SBOM 2 + id: sbom-2 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-2 }} + sbom-path: ${{ steps.subjects.outputs.sbom-2 }} + + - name: Attest SBOM 3 + id: sbom-3 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-3 }} + sbom-path: ${{ steps.subjects.outputs.sbom-3 }} + + - name: Attest SBOM 4 + id: sbom-4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-4 }} + sbom-path: ${{ steps.subjects.outputs.sbom-4 }} + + - name: Attest SBOM 5 + id: sbom-5 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-5 }} + sbom-path: ${{ steps.subjects.outputs.sbom-5 }} + + - name: Attest SBOM 6 + id: sbom-6 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-6 }} + sbom-path: ${{ steps.subjects.outputs.sbom-6 }} + + - name: Attest SBOM 7 + id: sbom-7 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-7 }} + sbom-path: ${{ steps.subjects.outputs.sbom-7 }} + + # The bundles become release assets, named after what they attest. + - name: Collect the attestation bundles + env: + PROVENANCE: ${{ steps.provenance.outputs.bundle-path }} + SBOM_1: ${{ steps.sbom-1.outputs.bundle-path }} + SBOM_2: ${{ steps.sbom-2.outputs.bundle-path }} + SBOM_3: ${{ steps.sbom-3.outputs.bundle-path }} + SBOM_4: ${{ steps.sbom-4.outputs.bundle-path }} + SBOM_5: ${{ steps.sbom-5.outputs.bundle-path }} + SBOM_6: ${{ steps.sbom-6.outputs.bundle-path }} + SBOM_7: ${{ steps.sbom-7.outputs.bundle-path }} + run: | + $ErrorActionPreference = 'Stop' + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + $bundles = [ordered]@{ "CheatEngine.Client.$env:VERSION.provenance.sigstore.json" = $env:PROVENANCE } + for ($index = 0; $index -lt $packageIds.Count; $index++) { + $bundles["$($packageIds[$index]).$env:VERSION.sbom.sigstore.json"] = [Environment]::GetEnvironmentVariable("SBOM_$($index + 1)") + } + foreach ($name in $bundles.Keys) { + if (-not $bundles[$name] -or -not (Test-Path -LiteralPath $bundles[$name] -PathType Leaf)) { + throw "The attestation bundle for $name was not produced." + } + Copy-Item -LiteralPath $bundles[$name] -Destination (Join-Path artifacts/attestations $name) + } + + # The attestations are checked where they are made, before anything is drafted or pushed: each subject against + # its bundle and against the attestations stored in the repository, with the identity that RELEASING.md gives + # consumers (this repository, the release.yml signer workflow, the tag and a hosted runner). + - name: Verify the attestations + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $identity = @( + '--repo', 'CheatEngineNet/CheatEngine.Client', + '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + $provenance = "artifacts/attestations/CheatEngine.Client.$env:VERSION.provenance.sigstore.json" + foreach ($subject in @(Get-ChildItem artifacts/nuget -File | Sort-Object Name)) { + gh attestation verify $subject.FullName @identity --predicate-type 'https://slsa.dev/provenance/v1' --bundle $provenance + if ($LASTEXITCODE -ne 0) { + throw "The build provenance of $($subject.Name) did not verify against its bundle (exit code $LASTEXITCODE)." + } + gh attestation verify $subject.FullName @identity --predicate-type 'https://slsa.dev/provenance/v1' + if ($LASTEXITCODE -ne 0) { + throw "The build provenance of $($subject.Name) did not verify in the repository (exit code $LASTEXITCODE)." + } + } + foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { + $package = "artifacts/nuget/$id.$env:VERSION.nupkg" + $sbom = "artifacts/attestations/$id.$env:VERSION.sbom.sigstore.json" + gh attestation verify $package @identity --predicate-type 'https://spdx.dev/Document/v2.2' --bundle $sbom + if ($LASTEXITCODE -ne 0) { + throw "The SBOM attestation of $id.$env:VERSION.nupkg did not verify against its bundle (exit code $LASTEXITCODE)." + } + gh attestation verify $package @identity --predicate-type 'https://spdx.dev/Document/v2.2' + if ($LASTEXITCODE -ne 0) { + throw "The SBOM attestation of $id.$env:VERSION.nupkg did not verify in the repository (exit code $LASTEXITCODE)." + } + } + + # sha256sum format (' ', sorted ordinally by name, LF) over every release asset: the packages, + # their symbol packages, the SBOMs and the attestation bundles. It travels with the bundles, so draft-release + # attaches it and publish checks every package against it before anything is pushed. + - name: Write SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + $assets = [System.Collections.Generic.SortedDictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($file in @(Get-ChildItem artifacts/nuget, artifacts/attestations -File)) { + $assets.Add($file.Name, $file.FullName) + } + $lines = foreach ($name in $assets.Keys) { + '{0} {1}' -f (Get-FileHash -LiteralPath $assets[$name] -Algorithm SHA256).Hash.ToLowerInvariant(), $name + } + [System.IO.File]::WriteAllText((Join-Path $PWD 'artifacts/attestations/SHA256SUMS'), (($lines -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) + Write-Output "Wrote SHA256SUMS ($($assets.Count) assets)." + + - name: Upload the attestation bundles, SBOMs and SHA256SUMS + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: attestation-bundles + path: artifacts/attestations + if-no-files-found: error + retention-days: 90 + + draft-release: + name: Draft GitHub release + needs: [ verify, ci, attest ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write # create the draft release and upload its assets + # GH_TOKEN (contents: write) is set only on the steps that call gh. + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-packages + path: artifacts/release + + - name: Download the attestation bundles, SBOMs and SHA256SUMS + 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/release-notes + + # Immutable releases lock a release's tag and assets once it is published, so the release is always created as a + # draft, filled completely, and only published by finalize-release after nuget.org serves the packages. Assets + # are never patched onto an existing release (that path breaks immutable releases): every draft already on the + # tag (left by an interrupted run or an earlier build; GitHub allows several) is deleted first, and the draft is + # created again from this run's files. gh release delete resolves a draft by its tag, so no release id is ever + # needed (the id of gh release view is a GraphQL node id that the REST API rejects), and it keeps the git tag. + # Nothing of a draft is public. A published release never receives assets: the job fails before anything is + # created. + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $files = @(Get-ChildItem artifacts/release -File | ForEach-Object FullName) + # The list includes drafts for this token (contents: write). + $list = gh release list --limit 1000 --json tagName,isDraft,createdAt + if ($LASTEXITCODE -ne 0) { + throw "Listing the releases of $env:GH_REPO failed with exit code $LASTEXITCODE." + } + $onTag = @(($list -join "`n") | ConvertFrom-Json | Where-Object { $_.tagName -ceq $env:TAG }) + if (@($onTag | Where-Object { -not $_.isDraft }).Count -gt 0) { + throw "Release $env:TAG is already published, and a published release never receives assets. Tag a new version." + } + + foreach ($draft in $onTag) { + Write-Output "::warning::A draft release of $env:TAG (created $($draft.createdAt)) already exists; it is deleted and created again from this run's files. The tag stays." + gh release delete $env:TAG --yes + if ($LASTEXITCODE -ne 0) { + throw "Deleting the draft release $env:TAG failed with exit code $LASTEXITCODE." + } + } + + $createOptions = @('--repo', $env:GH_REPO, '--draft', '--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes/release-notes.md') + if ($env:PRERELEASE -eq 'true') { + $createOptions += '--prerelease' + } + gh release create $env:TAG @files @createOptions + if ($LASTEXITCODE -ne 0) { + throw "Creating the draft release $env:TAG failed with exit code $LASTEXITCODE." + } + Write-Output "Created the draft release $env:TAG with $($files.Count) assets." + + # 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, draft-release ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' + runs-on: windows-2025 + timeout-minutes: 20 + environment: + name: nuget # required reviewers approve the publication; the environment only admits v*.*.* tags + url: https://www.nuget.org/packages/CheatEngine.Client/${{ needs.verify.outputs.version }} + permissions: + contents: read + id-token: write # exchange the OIDC token for a short-lived NuGet API key + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + # Only the .NET setup action and global.json: this job runs no repository script next to its credentials. + - name: Checkout the .NET setup + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github/actions + global.json + sparse-checkout-cone-mode: false + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + with: + cache: 'false' + + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-packages + path: artifacts/nuget + + - name: Download SHA256SUMS + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: attestation-bundles + path: artifacts/release-assets + + # Before any credential exists: every package and symbol package this job pushes has the SHA-256 that the + # SHA256SUMS of the draft release lists for it, and every package SHA256SUMS lists is here to be pushed. + - name: Check the packages against SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in @(Get-Content -LiteralPath artifacts/release-assets/SHA256SUMS)) { + if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { + throw "SHA256SUMS has a malformed or repeated line: '$line'." + } + } + + $packages = @(Get-ChildItem artifacts/nuget -File) + foreach ($package in $packages) { + if ($package.Extension -cnotin '.nupkg', '.snupkg') { + throw "The nuget-packages artifact holds $($package.Name), which is neither a .nupkg nor a .snupkg." + } + $actual = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $sums.ContainsKey($package.Name) -or $sums[$package.Name] -cne $actual) { + throw "$($package.Name) has SHA-256 $actual, which SHA256SUMS does not list for it." + } + Write-Output "$($package.Name): $actual" + } + + $pushed = @($packages | ForEach-Object Name) + $missing = @($sums.Keys | Where-Object { ($_.EndsWith('.nupkg', [System.StringComparison]::Ordinal) -or $_.EndsWith('.snupkg', [System.StringComparison]::Ordinal)) -and $pushed -cnotcontains $_ }) + if ($missing.Count -gt 0) { + throw "SHA256SUMS lists packages that the nuget-packages artifact does not hold: $($missing -join ', ')." + } + + # The user is the nuget.org profile name that created the trusted publishing policy, not an e-mail address. The + # key lasts one hour, so the login comes right before the pushes. + - name: NuGet login + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + # Dependency order: every package is pushed after the Client packages it depends on. --no-symbols keeps the + # symbol packages for the next step, so all seven packages are live before the first symbol package is pushed. + # --skip-duplicate lets a re-run of this job finish a partial publication. + - name: Push the seven packages + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { + $package = "artifacts/nuget/$id.$env:VERSION.nupkg" + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "$package is missing from the nuget-packages artifact." + } + dotnet nuget push $package --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --no-symbols --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "Pushing $id $env:VERSION failed with exit code $LASTEXITCODE." + } + } + + # Recovery: when this step fails, the seven packages are already live, and a version on nuget.org can never be + # replaced. Re-run the failed publish job: the package pushes above are skipped as duplicates, and only the symbol + # packages are pushed again, with --skip-duplicate for those nuget.org already accepted. + - name: Push the symbol packages + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + $symbols = @(Get-ChildItem artifacts/nuget -File -Filter '*.snupkg' | Sort-Object Name) + foreach ($symbol in $symbols) { + dotnet nuget push $symbol.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "Pushing the symbol package $($symbol.Name) failed with exit code $LASTEXITCODE." + } + } + Write-Output "Pushed $($symbols.Count) symbol packages." + + verify-publication: + name: Verify publication + needs: [ verify, publish ] + runs-on: windows-2025 + timeout-minutes: 60 + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + with: + cache: 'false' + + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-packages + path: artifacts/nuget + + # Resolves PackageBaseAddress/3.0.0 from the nuget.org service index, never a hard-coded flat container URL. For + # each of the seven packages, polls the flat container until it lists the version (validation and indexing take + # minutes), downloads the served file, and fails unless 'dotnet nuget verify --all' accepts it with the nuget.org + # repository signature (type Repository, service index https://api.nuget.org/v3/index.json), the content hash it + # prints equals the SHA-512 of the attested, unsigned package (the value consumers' lock files record), and the + # served file holds exactly the attested entries, byte for byte, plus .signature.p7s. + - name: Verify the published packages + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + # The checks read the English output of dotnet nuget verify, whatever the runner locale. + $env:DOTNET_CLI_UI_LANGUAGE = 'en' + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + $deadline = [DateTime]::UtcNow.AddMinutes(45) + $lowerVersion = $env:VERSION.ToLowerInvariant() + $work = Join-Path ([System.IO.Path]::GetTempPath()) ('published-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $work | Out-Null + + $serviceIndex = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json' -MaximumRetryCount 5 -RetryIntervalSec 10 + $resource = @($serviceIndex.resources | Where-Object { $_.'@type' -ceq 'PackageBaseAddress/3.0.0' }) | Select-Object -First 1 + if ($null -eq $resource) { + throw "The nuget.org service index has no PackageBaseAddress/3.0.0 resource." + } + $base = ([string] $resource.'@id').TrimEnd('/') + '/' + Write-Output "nuget.org PackageBaseAddress: $base" + + function Get-EntryHash { + param([string] $Path) + $hashes = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + foreach ($entry in $archive.Entries) { + if ($entry.FullName.EndsWith('/')) { + continue + } + $stream = $entry.Open() + try { + $hashes.Add($entry.FullName, [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($stream))) + } + finally { + $stream.Dispose() + } + } + } + finally { + $archive.Dispose() + } + return $hashes + } + + try { + foreach ($id in $packageIds) { + $attested = Join-Path $PWD "artifacts/nuget/$id.$env:VERSION.nupkg" + if (-not (Test-Path -LiteralPath $attested -PathType Leaf)) { + throw "The attested package $id.$env:VERSION.nupkg is missing from artifacts/nuget." + } + + $lowerId = $id.ToLowerInvariant() + while ($true) { + $index = Invoke-RestMethod -Uri "$base$lowerId/index.json" -SkipHttpErrorCheck -StatusCodeVariable status -MaximumRetryCount 3 -RetryIntervalSec 5 + if ($status -eq 200 -and (@($index.versions) -contains $lowerVersion)) { + break + } + if ([DateTime]::UtcNow -gt $deadline) { + throw "nuget.org does not list $id $env:VERSION after 45 minutes (last HTTP status $status)." + } + Write-Output "Waiting for nuget.org to list $id $env:VERSION (HTTP $status)." + Start-Sleep -Seconds 30 + } + + $signed = Join-Path $work "$lowerId.$lowerVersion.nupkg" + Invoke-WebRequest -Uri "$base$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg" -OutFile $signed -MaximumRetryCount 3 -RetryIntervalSec 5 + $verification = (@(dotnet nuget verify --all $signed -v n 2>&1) | ForEach-Object { "$_" }) -join "`n" + if ($LASTEXITCODE -ne 0) { + throw "dotnet nuget verify --all failed for $id $env:VERSION with exit code ${LASTEXITCODE}:`n$verification" + } + if ($verification -cnotmatch '(?m)^Signature type: Repository\s*$' -or $verification -cnotmatch '(?m)^Service index: https://api\.nuget\.org/v3/index\.json\s*$') { + throw "dotnet nuget verify did not report the nuget.org repository signature of $id $env:VERSION. Its output was:`n$verification" + } + $expected = [Convert]::ToBase64String([System.Security.Cryptography.SHA512]::HashData([System.IO.File]::ReadAllBytes($attested))) + if ($verification -cnotmatch '(?m)^Content hash:\s*(?\S+)\s*$' -or $Matches['hash'] -cne $expected) { + throw "$id $env:VERSION on nuget.org does not have the content hash of the attested package ($expected)." + } + + $attestedEntries = Get-EntryHash -Path $attested + $signedEntries = Get-EntryHash -Path $signed + if ($attestedEntries.ContainsKey('.signature.p7s') -or -not $signedEntries.Remove('.signature.p7s')) { + throw "$id $env:VERSION on nuget.org has no .signature.p7s entry, or the attested package already had one." + } + $names = [System.Collections.Generic.HashSet[string]]::new([string[]] @($attestedEntries.Keys), [System.StringComparer]::Ordinal) + $names.UnionWith([string[]] @($signedEntries.Keys)) + $differences = @($names | Where-Object { -not $attestedEntries.ContainsKey($_) -or -not $signedEntries.ContainsKey($_) -or $attestedEntries[$_] -cne $signedEntries[$_] }) + if ($differences.Count -gt 0) { + throw "$id $env:VERSION on nuget.org differs from the attested package in: $($differences -join ', ')." + } + + Write-Output "nuget.org serves the attested $id $env:VERSION with its repository signature." + } + } + finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } + + finalize-release: + name: Finalize GitHub release + needs: [ verify, draft-release, verify-publication ] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write # publish the draft release + attestations: read # verify the attestations + # GH_TOKEN (contents: write) is set only on the steps that call gh. + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.verify.outputs.version }} + # attest verified the attestations before anything became public; this job verifies what consumers download. + steps: + # gh release view and gh release edit find a draft by its tag, which the REST lookup by tag never returns (it + # serves published releases only). A release that is already published, on a re-run, is verified again but never + # edited. + - name: Read the release state + id: state + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $view = gh release view $env:TAG --json isDraft,isImmutable + if ($LASTEXITCODE -ne 0) { + throw "Reading the release $env:TAG failed with exit code $LASTEXITCODE." + } + $state = ($view -join "`n") | ConvertFrom-Json + $draft = ([bool] $state.isDraft).ToString().ToLowerInvariant() + Write-Output "Release $env:TAG is a draft: $draft; immutable: $(([bool] $state.isImmutable).ToString().ToLowerInvariant())." + "draft=$draft" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Publish the release + if: steps.state.outputs.draft == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release edit $env:TAG --draft=false + if ($LASTEXITCODE -ne 0) { + throw "Publishing the release $env:TAG failed with exit code $LASTEXITCODE." + } + + # Verifies what consumers download, not the local artifacts: every asset against SHA256SUMS, the release + # attestation when the release is immutable (immutable releases are a repository setting, RELEASING.md), and the + # provenance and SBOM attestations with the identity that attest checked. + - name: Verify the published release + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $view = gh release view $env:TAG --json isDraft,isImmutable + if ($LASTEXITCODE -ne 0) { + throw "Reading the release $env:TAG failed with exit code $LASTEXITCODE." + } + $state = ($view -join "`n") | 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 + if ($LASTEXITCODE -ne 0) { + throw "Downloading the assets of $env:TAG failed with exit code $LASTEXITCODE." + } + + # Every line of SHA256SUMS names an asset with that SHA-256, and every other asset has a line. + $sumsPath = Join-Path $published 'SHA256SUMS' + if (-not (Test-Path -LiteralPath $sumsPath -PathType Leaf)) { + throw "Release $env:TAG has no SHA256SUMS asset." + } + $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in [System.IO.File]::ReadAllLines($sumsPath)) { + if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { + throw "SHA256SUMS of $env:TAG has a malformed or repeated line: '$line'." + } + } + foreach ($asset in @(Get-ChildItem -LiteralPath $published -File | Where-Object Name -cne 'SHA256SUMS')) { + if (-not $sums.ContainsKey($asset.Name)) { + throw "Release $env:TAG has the asset $($asset.Name), which SHA256SUMS does not list." + } + } + [string[]] $names = @($sums.Keys) + [System.Array]::Sort($names, [System.StringComparer]::Ordinal) + foreach ($name in $names) { + $path = Join-Path $published $name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "SHA256SUMS lists $name, which release $env:TAG does not carry." + } + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $sums[$name]) { + throw "The published $name has SHA-256 $actual; SHA256SUMS lists $($sums[$name])." + } + } + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + $packages = @($packageIds | ForEach-Object { "$_.$env:VERSION.nupkg" }) + $symbols = @($names | Where-Object { $_.EndsWith('.snupkg', [System.StringComparison]::Ordinal) }) + $expected = @($packageIds | ForEach-Object { "$_.$env:VERSION.nupkg", "$_.$env:VERSION.spdx.json", "$_.$env:VERSION.sbom.sigstore.json" }) + + "CheatEngine.Client.$env:VERSION.provenance.sigstore.json" + $missing = @($expected | Where-Object { -not $sums.ContainsKey($_) }) + if ($missing.Count -gt 0) { + throw "Release $env:TAG lacks the assets $($missing -join ', ')." + } + + # Only an immutable release has a release attestation. SHA256SUMS, checked above, ties every other asset to it. + if ($state.isImmutable) { + gh release verify $env:TAG + if ($LASTEXITCODE -ne 0) { + throw "gh release verify $env:TAG failed with exit code $LASTEXITCODE." + } + foreach ($name in @($packages) + @($symbols) + 'SHA256SUMS') { + gh release verify-asset $env:TAG (Join-Path $published $name) + if ($LASTEXITCODE -ne 0) { + throw "gh release verify-asset $env:TAG $name failed with exit code $LASTEXITCODE." + } + } + } + else { + Write-Output "::warning::Immutable releases are not enabled for $env:GH_REPO, so release $env:TAG has no release attestation to verify. Enable them (RELEASING.md)." + } + + $identity = @( + '--repo', 'CheatEngineNet/CheatEngine.Client', + '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + foreach ($name in @($packages) + @($symbols)) { + gh attestation verify (Join-Path $published $name) @identity --predicate-type 'https://slsa.dev/provenance/v1' + if ($LASTEXITCODE -ne 0) { + throw "The build provenance attestation of the published $name did not verify (exit code $LASTEXITCODE)." + } + } + foreach ($name in $packages) { + gh attestation verify (Join-Path $published $name) @identity --predicate-type 'https://spdx.dev/Document/v2.2' + if ($LASTEXITCODE -ne 0) { + throw "The SBOM attestation of the published $name did not verify (exit code $LASTEXITCODE)." + } + } + + $summary = @( + "### Release $env:TAG", + '', + "Published; immutable: $(([bool] $state.isImmutable).ToString().ToLowerInvariant()). Every asset matches SHA256SUMS; the provenance of each package and symbol package and the SBOM attestation of each package verify with the release.yml identity.", + '', + '| Asset | SHA-256 |', + '| --- | --- |' + ) + @($names | ForEach-Object { "| ``$_`` | ``$($sums[$_])`` |" }) + $summary | 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 0000000..a2d601b --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,54 @@ +# Advisory OpenSSF Scorecard (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 or job-level `env` and +# `defaults`, workflow-level write permissions, `id-token: write` outside this job, `container`/`services`, `run:` +# steps and actions outside its allowlist. This file therefore deliberately does NOT follow the repository's +# `defaults: run: shell: pwsh` convention. Publication works from the default branch only (post-merge check). +# Expected low checks, explained rather than fixed: Code-Review (single maintainer), Branch-Protection (until branch +# protection rules are configured on this repository), Signed-Releases (until the first release), SAST (until CodeQL +# has history). +name: Scorecard + +on: + branch_protection_rule: + push: + branches: [ main ] + schedule: + - cron: '41 5 * * 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: signed upload to the Scorecard API + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # No repo_token: rulesets are readable with the default token. + - name: Run Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - 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 c75b941..aa66efd 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,13 +1,16 @@ name: Sonar +# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true. It reuses the +# coverage and nuget-packages artifacts of the same run; only the instrumented build is repeated, because the scanner +# must observe a compilation. +# +# CI-based analysis only: Automatic Analysis must stay OFF for the project in SonarQube Cloud (Administration > Analysis +# Method). SonarQube Cloud exposes no API to read that setting; with both methods on, scanner begin fails, which is the +# earliest authoritative check. + on: workflow_call: inputs: - ci_based_analysis: - description: Acknowledge that SonarQube Cloud Automatic Analysis is disabled for this project. - required: false - type: boolean - default: true project-key: description: SonarQube Cloud project key. type: string @@ -16,130 +19,92 @@ on: description: SonarQube Cloud organization key. type: string default: cheatenginenet + wait-quality-gate: + description: Fail the job when the quality gate fails. Pull requests and manual runs wait for it; main only reports it. + type: boolean + default: true secrets: SONAR_TOKEN: - description: SonarQube Cloud token for protected, same-repository analysis. - required: true + description: SonarQube Cloud analysis token. The job fails fast when it is empty. + required: false permissions: contents: read +env: + SONAR_SCANNER_VERSION: 11.3.0 + defaults: run: shell: pwsh -env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - MSBUILDDISABLENODEREUSE: true - SONAR_SCANNER_VERSION: 11.3.0 - jobs: analyze: name: Analyze - runs-on: windows-latest - timeout-minutes: 25 + runs-on: windows-2025 + timeout-minutes: 30 env: SONAR_PROJECT_KEY: ${{ inputs.project-key }} SONAR_ORGANIZATION: ${{ inputs.organization }} - + SONAR_WAIT_QUALITY_GATE: ${{ inputs.wait-quality-gate }} steps: - # SonarQube Cloud has no supported API that reads a project's Automatic Analysis setting. The scanner begin - # call below is the earliest authoritative service-side preflight and fails before restore/build if Automatic - # Analysis was not disabled in the project's Administration > Analysis Method UI. - - name: Preflight CI-based analysis - env: - CI_BASED_ANALYSIS: ${{ inputs.ci_based_analysis }} - run: | - if ($env:CI_BASED_ANALYSIS -ne 'true') { - throw 'Sonar analysis requires CI-based analysis. Disable Automatic Analysis in SonarQube Cloud before enabling this workflow.' - } - - $sonarUserHome = Join-Path $env:RUNNER_TEMP 'sonar-user-home' - "SONAR_USER_HOME=$sonarUserHome" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - @( - '### Sonar analysis mode', - '', - 'CI-based analysis is enabled for this run. SonarQube Cloud Automatic Analysis must remain disabled for this project.' - ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - - name: Require Sonar token + # ci.yml never calls this workflow for forks or Dependabot, so an empty token is a configuration error. Fail + # before the instrumented build instead of after it. + - name: Require token env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | - if ([string]::IsNullOrWhiteSpace($env:SONAR_TOKEN)) { - throw 'SONAR_TOKEN is required when SONAR_CI_ENABLED is true.' + if ([string]::IsNullOrEmpty($env:SONAR_TOKEN)) { + Write-Host '::error title=SONAR_TOKEN missing::Add the repository secret SONAR_TOKEN (a SonarQube Cloud analysis token for this project).' + exit 1 } - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + fetch-depth: 0 # SCM blame for new-code detection persist-credentials: false + # SonarScanner for .NET uses Java. The same JDK 21 distribution as CheatEngine.SDK keeps scanner behaviour + # reproducible across both repositories. - name: Set up JDK 21 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: zulu java-version: '21' - - name: Install pinned .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - # This secret-bearing job restores only through the generated NuGet.Config below. It deliberately does not - # restore a global package cache that another PR workflow could have populated. - cache: false + # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet - # Only main saves this cache. Internal PRs may restore a known main-branch analyzer cache but cannot publish one. + # Every event may restore the analyzer cache; only main saves it (last step), so a pull request can read a + # main-branch cache but never publish one. - name: Restore Sonar analyzer cache id: sonar-cache-restore - uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/sonar-user-home/cache key: sonar-analyzers-${{ runner.os }}-${{ runner.arch }}-${{ env.SONAR_SCANNER_VERSION }}-v1 - - name: Download Sonar coverage reports - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Download coverage + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: sonar-coverage - path: artifacts/sonar-test-results - - - name: Verify coverage reports - run: | - $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File) - $reports = @($projects | ForEach-Object { - Join-Path (Join-Path 'artifacts/sonar-test-results' $_.BaseName) 'coverage.xml' - }) - $missingReports = @($reports | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }) - if ($missingReports.Count -gt 0) { - throw "Missing Sonar XML coverage report(s): $($missingReports -join ', ')." - } - - foreach ($report in $reports) { - if ((Get-Item -LiteralPath $report).Length -eq 0) { - throw "Coverage report '$report' is empty." - } - - try { - $coverage = [xml](Get-Content -LiteralPath $report -Raw) - } - catch { - throw "Coverage report '$report' is not valid XML. $($_.Exception.Message)" - } - - if ($null -eq $coverage.DocumentElement) { - throw "Coverage report '$report' has no XML document element." - } - } + name: coverage + path: ${{ runner.temp }}/coverage - Write-Host "Verified $($reports.Count) non-empty XML coverage report(s)." + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-packages + path: ${{ runner.temp }}/packages - - name: Create isolated NuGet configuration - id: isolated-nuget-config + # Do not use the checked-out nuget.config to resolve tooling. The scanner package and every restored dependency + # must come only from nuget.org, not a source a pull request can redirect. + - name: Create NuGet.org-only configuration + id: nuget-config run: | - $configPath = Join-Path $env:RUNNER_TEMP 'sonar-nuget.config' + $ErrorActionPreference = 'Stop' + $path = Join-Path $env:RUNNER_TEMP 'sonar-nuget.config' @' @@ -148,25 +113,54 @@ jobs: - '@ | Set-Content -LiteralPath $configPath -Encoding utf8NoBOM + '@ | Set-Content -LiteralPath $path -Encoding utf8NoBOM + "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - "path=$configPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + # Restore before scanner begin, in locked mode: the analysis build is --no-restore, so no pull-request-supplied + # source or version can take part while scanner credentials are configured. + - name: Restore from NuGet.org (locked) + env: + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} + run: | + dotnet restore CheatEngine.Client.slnx --locked-mode --configfile $env:NUGET_CONFIG + if ($LASTEXITCODE -ne 0) { + throw "Sonar restore failed with exit code $LASTEXITCODE." + } - - name: Install scanner from nuget.org only + - name: Install scanner env: - NUGET_CONFIG: ${{ steps.isolated-nuget-config.outputs.path }} + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} run: | - dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" --version $env:SONAR_SCANNER_VERSION --configfile "$env:NUGET_CONFIG" + dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" --version $env:SONAR_SCANNER_VERSION --configfile $env:NUGET_CONFIG if ($LASTEXITCODE -ne 0) { - throw "dotnet-sonarscanner installation failed with exit code $LASTEXITCODE." + throw "SonarScanner installation failed with exit code $LASTEXITCODE." } + # The begin step ignores the SONAR_TOKEN variable, so the token travels in SONARQUBE_SCANNER_PARAMS, which both + # scanner steps read. It stays off the command line and out of the build step that compiles pull-request code. - name: Begin analysis env: SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home run: | - # These findings conflict with deliberate repository contracts. Keep them in scanner configuration so the - # source remains free of Sonar-only attributes and suppressions. Test projects are excluded explicitly below. + $coverage = "$env:RUNNER_TEMP/coverage" + $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) + if ($reports.Count -eq 0) { + Write-Host '::error::The coverage artifact holds no report.' + exit 1 + } + # The new-code period follows sonar.projectVersion: the core version of the facade package built in this run + # (0.1.0 for 0.1.0-alpha.0.37). The digit right after "CheatEngine.Client." excludes the other package ids. + $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/packages" -Filter 'CheatEngine.Client.*.nupkg' -File | + Where-Object { $_.Name -match '^CheatEngine\.Client\.\d' }) + if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.Client\.(?\d+\.\d+\.\d+)') { + Write-Host '::error::The nuget-packages artifact must hold exactly one CheatEngine.Client facade package.' + exit 1 + } + $version = $Matches['version'] + Write-Host "Coverage reports: $($reports.Count); project version: $version" + # These findings conflict with deliberate repository contracts. Keep them in the scanner configuration so the + # sources stay free of Sonar-only attributes and suppressions. $ignoredIssues = @( @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } @@ -175,15 +169,30 @@ jobs: @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/**' } @{ Key = 'generatorComments'; Rule = 'csharpsquid:S125'; Resource = 'source-generators/**' } + # The qualification fault switch makes an activation-scoped resource throw from Dispose on purpose (Q06, Q43). + @{ Key = 'harnessFaultInjection'; Rule = 'csharpsquid:S3877'; Resource = 'tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs' } ) $arguments = @( "/k:$env:SONAR_PROJECT_KEY" "/o:$env:SONAR_ORGANIZATION" + "/v:$version" '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.Client.Benchmarks/**' - '/d:sonar.dotnet.excludeTestProjects=true' - '/d:sonar.cs.vscoveragexml.reportsPaths=artifacts/sonar-test-results/*/coverage.xml' + # CI publishes managed coverage of the shipping code only: tests and build tooling stay out of the coverage + # metric instead of presenting an incomplete report as if it covered them. Every excluded path exists + # (the repository has no documentation folder; its Markdown files are not analysed code). + # Shipping code is excluded file by file, never by a folder pattern, so every other file in libs/** keeps + # full coverage accounting. Each listed file is an SDK production adapter that no hosted test can reach + # (xUnit has no attached Cheat Engine process or Lua state): its lines are untestable, not merely + # untested, and its non-SDK branches keep the tests they allow next to it (for example + # SdkTableRecordMutationPortTests). A lot that adds an Sdk*Port.cs adds its own entry here. + '/d:sonar.coverage.exclusions=tests/**,eng/**,libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs,libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs,libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs,libs/CheatEngine.Client.Core/Domains/SdkRuntimeObservationPort.cs,libs/CheatEngine.Client.Core/Domains/SdkProcessSelectionPort.cs,libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs,libs/CheatEngine.Client.Core/Domains/SdkTableFilePort.cs,libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs,libs/CheatEngine.Client.Core/Domains/ValueScanning/SdkValueScanPort.cs,libs/CheatEngine.Client.Core/Domains/Allocations/SdkAllocationPort.cs,libs/CheatEngine.Client.Core/Domains/Assembly/SdkAutoAssemblerPort.cs,libs/CheatEngine.Client.Core/Domains/Assembly/SdkInstructionPort.cs' + # Never set sonar.dotnet.excludeTestProjects: the scanner then strips every analyzer, including source + # generators, from test projects, and their [GeneratedRegex]/[LoggerMessage] partial methods fail with + # CS8795 (https://github.com/SonarSource/sonar-scanner-msbuild/issues/1469). Test projects are analysed as + # test code instead. + "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" - '/d:sonar.qualitygate.wait=true' + "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" '/d:sonar.qualitygate.timeout=300' ) foreach ($issue in $ignoredIssues) { @@ -192,40 +201,37 @@ jobs: } & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments if ($LASTEXITCODE -ne 0) { - throw "Sonar begin analysis failed with exit code $LASTEXITCODE." - } - - - name: Restore locked dependency graph from nuget.org only - env: - NUGET_CONFIG: ${{ steps.isolated-nuget-config.outputs.path }} - run: | - dotnet restore CheatEngine.Client.slnx --locked-mode --configfile "$env:NUGET_CONFIG" - if ($LASTEXITCODE -ne 0) { - throw "dotnet restore failed with exit code $LASTEXITCODE." + throw "SonarScanner begin failed with exit code $LASTEXITCODE." } + # Debug, like the coverage leg. The scanner turns TreatWarningsAsErrors off for this build, which is why + # build-test, not this job, gates compilation. - name: Build + env: + SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home run: | - dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --no-incremental --disable-build-servers + dotnet build CheatEngine.Client.slnx -c Debug --no-restore --no-incremental --disable-build-servers if ($LASTEXITCODE -ne 0) { - throw "dotnet build failed with exit code $LASTEXITCODE." + throw "Sonar analysis build failed with exit code $LASTEXITCODE." } - - name: End analysis and wait for quality gate + - name: End analysis env: SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home run: | & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end if ($LASTEXITCODE -ne 0) { - throw "Sonar end analysis failed with exit code $LASTEXITCODE." + throw "SonarScanner end failed with exit code $LASTEXITCODE." } + # The only cache a release-reachable workflow touches: analyzer plugins, saved from main only. - name: Save Sonar analyzer cache from main if: >- ${{ success() && github.ref == 'refs/heads/main' && steps.sonar-cache-restore.outputs.cache-hit != 'true' }} - uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/sonar-user-home/cache key: sonar-analyzers-${{ runner.os }}-${{ runner.arch }}-${{ env.SONAR_SCANNER_VERSION }}-v1 diff --git a/.github/workflows/zizmor-online.yml b/.github/workflows/zizmor-online.yml new file mode 100644 index 0000000..bd1ea83 --- /dev/null +++ b/.github/workflows/zizmor-online.yml @@ -0,0 +1,47 @@ +# Advisory online workflow audits (known-vulnerable-actions, impostor-commit, ref-version-mismatch, stale-action-refs) +# uploaded to code scanning. Not part of CI / Gate: with advanced-security the action reports through SARIF and does +# not fail on findings. The blocking, offline zizmor run lives in ci.yml (job lint) and pins the same version. +name: Workflow security (online) + +on: + pull_request: + paths: + - '.github/**' + push: + branches: [ main ] + schedule: + - cron: '11 6 * * 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 + +jobs: + zizmor: + name: zizmor (online audits) + # Fork pull requests get a read-only token and cannot upload SARIF. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + security-events: write # upload the SARIF results + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Inputs: https://github.com/zizmorcore/zizmor-action (v0.6.4 action.yml). + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: true + advanced-security: true + persona: regular + config: .github/zizmor.yml diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..5e9223c --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,16 @@ +# zizmor configuration (https://docs.zizmor.sh/configuration/), used by the offline audit of the ci.yml "Lint" job and +# auto-discovered by a local 'zizmor --offline .github'. Only justified exceptions live here; every entry carries its +# reason. Never weaken a finding by changing the persona instead. + +rules: + # The composite action appends three constant CLI settings (DOTNET_NOLOGO, DOTNET_CLI_TELEMETRY_OPTOUT, + # MSBUILDDISABLENODEREUSE) to GITHUB_ENV, as the shared CI contract requires. No expression, input or file content + # reaches the written text, so nothing attacker-controlled can inject a variable. The line is the step's run: key. + github-env: + ignore: + - action.yml:34 + + # zizmor suggests GitHub's 'uses: $/...' self-repository syntax, but actionlint 1.7.12 (the pinned linter of the same + # job) rejects it ("invalid format because ref is missing"). Keep the './' form, as CheatEngine.SDK does. + self-repository: + disable: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..891492f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,306 @@ +# Changelog + +All notable changes to the CheatEngine.Client packages are documented in this file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/). The seven packages (`CheatEngine.Client`, `.Abstractions`, `.Core`, +`.Extensions.DependencyInjection`, `.Fluent`, `.Hosting` and `.Templates`) always share one version. + +Each release is a `## [X.Y.Z] - YYYY-MM-DD` section dated in ISO 8601, and its body becomes the notes of the GitHub +release. Earlier builds packed `0.1.0` from source, but no `CheatEngine.Client*` package exists on nuget.org before +1.0.0, so this file starts at the first release. + +Each release separates four kinds of change, because a consumer reacts to each differently: + +- **Added**: an extension, such as a new API, option or package asset. Existing code keeps its meaning. +- **Changed**: a semantic correction. An existing API returns, throws, cancels or cleans up differently, even when its + signature is unchanged. +- **Security**: a hardening. An operation that used to proceed is now refused, refused earlier or gated behind an + opt-in, or data that used to reach a log no longer does. +- **Deployment**: a change to what is built, packed, pinned, signed or published, and to how a plugin is deployed. + +The 1.0.0 section also has a **Removed** list: what the repository offered before its first release and 1.0.0 does not +ship. + +## [Unreleased] + +### Added + +### Changed + +### Security + +### Deployment + +## [1.0.0] - 2026-09-25 + +The first release of CheatEngine.Client: high-level, activation-scoped C# APIs for Cheat Engine 7.7 x64 plugins, built +on CheatEngine.SDK 2.0.0. It fixes the public API for the 1.x line, under the versioning rules of the +[README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/README.md#versioning-and-compatibility) and the +public API charter of the `CheatEngine.Client.Abstractions` README; an API that carries an experimental diagnostic id +(`CECLIENT5001` to `CECLIENT5004`) stays outside that promise until the id is lifted. **Changed** and **Removed** +compare this release with the `0.1.0` builds of the repository, which were never published. They summarize by contract +the changes a consumer of those builds meets, not every renamed member: the `PublicAPI` files of each project list the +exact surface. + +### Added + +- **One client per activation.** `CheatEngineClientPlugin` builds a new DI provider, scope and `ICheatEngineClient` on + every enable, starts the `ICheatEngineClientModule`s in registration order, stops them in reverse order and releases + every Client-owned Cheat Engine resource while the SDK context is still valid. `ICheatEngineClient` exposes + `Dispatcher`, `Runtime`, `Processes`, `Memory`, `Patterns`, `Inspection`, `Tables`, `Lua`, `ValueScans`, + `Allocations` and `Assembly`; `Epoch` and `Stopping` identify the activation. +- **Capability matrix.** `ICheatEngineRuntime.TryGetClientCapability` reports each of the eleven `ClientCapabilityId`s + with its `Evidence`: separate implementation, package, host, live qualification, policy and lifetime gates, and a + typed `EffectiveReasonCode`; a capability reports `Available` only when all six gates are satisfied. Every capability + has an operational adapter. The package gate accepts a loaded CheatEngine.SDK 2.x at or above 2.0.0, and the + qualification gate stays `Unknown` until the Client embeds committed evidence that every live scenario of the + capability succeeded, none waived (`Client.UnsafeLuaExecution` has no scenario, so it stays `Unknown`). The README + capability tables state the same matrix, and the features Cheat Engine offers without a CheatEngine.SDK primitive are + listed as not offered. +- **Value scans, allocations, instructions and Auto Assembler patches.** Four operational adapters, each assigned an + experimental diagnostic id that keeps its API `[Experimental]` until the live scenarios of its capability succeed and + the id is lifted; the README capability tables say which of these ids this release carries. While an API carries its + id, suppressing the diagnostic is the opt-in, and the diagnostic links to its section of the Abstractions README + (scope, behavior, known limits and exit criteria): + - `CECLIENT5001`, value scans: `ICheatEngineClient.ValueScans` creates `IValueScanSession`s over CheatEngine.SDK's + owned `MemScan` and `FoundList`, with first and next scans and pages of at most 1024 copied results; + `ValueScanValue.FromSingle` and `FromDouble` write the value in fixed point with the decimals the caller passes; + - `CECLIENT5002`, target allocations: `IAllocationClient.Allocate` returns an `ITargetMemoryLease` over + CheatEngine.SDK's `AllocatedRegion` (`AllocationProtection.ReadWrite` or `ExecuteReadWrite`), freed only in the + process incarnation that made it; + - `CECLIENT5003`, instructions: `IAssemblyClient` assembles, disassembles and measures one instruction per call, + checked against the target's instruction profile, and never writes target memory; + - `CECLIENT5004`, Auto Assembler patches: `IAutoAssemblerClient` checks a script, or applies it and returns an + `IAutoAssemblerPatchLease` that owns the disable information Cheat Engine returned; it is registered only by + `EnableAutoAssemblerPatches()`. +- **Leases and release outcomes.** Every resource the Client creates for the caller is an `ICheatEngineLease`: + symbol registrations, Lua modules, value-scan sessions, allocations and Auto Assembler patches. `Release()` returns a + `LeaseReleaseOutcome` (a `LeaseReleaseKind`, the host effect, and exactly one of `IsComplete`, `IsRetryable` and + `RequiresManualRecovery`), `Dispose()` never throws, `LastReleaseOutcome` keeps the attempt that ended the lease, and + `RequiresManualRecovery` says that what the lease owned may remain. Only `Unknown` and `CleanupUnavailable` are + retryable, as in CheatEngine.SDK; a lease still incomplete at disable is reported in the aggregated deactivation + failure. A target-bound lease exposes the `SelectionEpoch` of the process CheatEngine.SDK bound it to, and + `ILuaModuleLease.LastModuleReleaseOutcome` keeps what the module reported. +- **Failure vocabulary.** `CheatEngineFailure.HostEffect` says how far the Cheat Engine primitive got: `NotStarted`, + `Started`, `Completed`, `NotApplied`, `CleanupUnconfirmed` or `Unknown`. New failure kinds: + `IndeterminateHostResult` (documented causes that cannot be told apart, never absence), `TargetChanged`, + `TargetIdentityUnavailable` and `RuntimeChanged`. `CheatEngineFailure.Throw(token)` throws a failure's exception and + `ToException(token)` creates it. +- **Pattern scans.** `IPatternScanner.ScanDetailed` returns a `PatternScanOutcome`: the classification of `TryScan`, + `PatternScanMetrics` (the route, the host result count, the examined, filtered-out, copied and unread counts, and + Cheat Engine's scan time apart from the copy time), the `PatternScanHostOutcomeKind`, the `PatternScanRouteReason` + and `TargetIdentityVerified`. A module or range scan runs Cheat Engine's bounded MemScan on a qualified local target + and falls back to one global `AOBScan` with managed filters elsewhere, whose addresses are never reported as + identity-verified. Scan options are Client values (`ScanProtectionFilter`, `ScanAlignment`) that Core translates the + same way on every route; the default filter is Cheat Engine's empty "find everything" protection text. +- **Memory.** `IMemoryClient.ReadBytesDetailed` returns the confirmed prefix of a partial byte read + (`MemoryBytesReadOutcome`), and `ReadPrimitiveBatchDetailed` and `WritePrimitiveBatchDetailed` report + `RequestedCount`, `CompletedCount` and a `MemoryBatchWriteEffectState`. Pointer-typed operations use + CheatEngine.SDK's width-qualified reads and writes at the observed process width, and codec contexts expose `Bitness` + and the configured pointer size. +- **Runtime and target facts.** `CheatEngineRuntimeSnapshot` groups the four-part Cheat Engine version, the loaded + CheatEngine.SDK version and whether it is the reviewed package (`Version`), and the host operating system and + architecture, `CheatEngineBitness`, the `TargetBackend`, the target architecture, `TargetBitness` and the configured + pointer size (`Platform`); a fact CheatEngine.SDK could not establish stays unknown. `ProcessSnapshot` adds `Backend`, + `StartTimeUtc` and the configured pointer size next to its `Bitness` (`ConfiguredPointerSize`, + `ConfiguredPointerSizeBytes`, `ConfiguredPointerSizeDiffersFromBitness`), and `IProcessClient.TryGetLocalProcesses` + reads the local process catalog offline, without an activation. +- **Address List and symbols.** `ITableClient` counts records (`GetRecordCount`), reads one by index (`GetRecordAt`), + reads the selected record and selects one (`GetSelectedRecord`, `SelectRecord`), and loads and saves trusted tables + through CheatEngine.SDK's `CheatTableFiles`; a trusted load makes every earlier `MemoryRecordId` stale. + `IInspectionClient.TryRegisterSymbol` registers through CheatEngine.SDK's owned registration after a collision check, + and `TryResolveAddress` takes an `AddressResolutionMode`. +- **Lua.** Generated Lua modules register through CheatEngine.SDK 2.0.0 registration leases, and the generator reports + the module shape errors `CECLUA1201` to `CECLUA1204`. +- **Fluent entry points.** Each domain has one entry point, an extension method bound to its service when the builder + is created: `memory.At(address)` and `memory.Batch()` on an `IMemoryClient`, and `scanner.Aob(pattern)` on an + `IPatternScanner`. The AOB builder takes typed options (`Writable()`, `WithProtection`, `AlignedTo`, `LastDigits`, + `WithAlignment`), and every terminal documents the exceptions it throws. +- **Hosting.** `CheatEnginePluginBuilder.Logging` is the `ILoggingBuilder` of the activation provider, and + `PluginDirectory` is the folder of the plugin assembly, the base for the plugin's own files. + `builder.Logging.AddCheatEngineHostLog()` adds an opt-in logging provider that writes to CheatEngine.SDK's host log. + Event 20 (`ActivationIdentified`) identifies each activation with the Client version and the consumed and loaded + CheatEngine.SDK, and event 8 (`ExternalLuaStateResetDetected`) reports a Lua state that Cheat Engine replaced outside + the plugin's control, read from CheatEngine.SDK's flag after the Client resources are released, without a snapshot. +- **Build diagnostics.** The Hosting README catalogs the consumer diagnostics `CECLIENT001` to `CECLIENT017`, and each + diagnostic carries a help link to its entry. +- **Template.** `dotnet new ceplugin` derives the plugin's display name and its Lua status global (ASCII + lower_snake_case with a `_status` suffix) from the project name, creates a folder named after the project, honors + `--no-restore` and ships a `.gitignore`. Different project names can derive the same global, which only one plugin + can own. The generated plugin reads its configuration from `PluginDirectory` and adds no logging provider; its README + documents the opt-in `AddCheatEngineHostLog()`. +- **Documentation.** Every public member documents its parameters, return value and exceptions, which a test checks. + The packed READMEs are written for plugin authors, and their C# snippets compile against the packed packages. Tests + also tie the READMEs' 1.0 status columns to the experimental APIs, the diagnostic catalog's severities to the + diagnostics and each documented `PackageReference` version to the compiled one. + +### Changed + +- **Cancellation.** A throwing form whose token is observed throws `CheatEngineOperationCanceledException`, an + `OperationCanceledException` whose `Failure.HostEffect` says whether Cheat Engine work had started. A token is + observed before dispatch and between Client-managed steps, and never interrupts a Cheat Engine call that has started. + A batch write cancelled before dispatch reports `MemoryBatchWriteEffectState.NotStarted`. +- **Exceptions.** The exception type depends only on the failure kind: `Cancelled` gives + `CheatEngineOperationCanceledException`, `ActivationExpired` `CheatEngineActivationExpiredException`, `InvalidState` + `CheatEngineInvalidStateException` (formerly `CheatEngineClientLifecycleException`), and every other kind + `CheatEngineOperationException`. Every exception keeps the complete failure, and no Client exception has a public + constructor: `CheatEngineFailure.Throw(token)` and `ToException(token)` create them. +- **Arguments first.** A null argument, a `default` request, an undefined enum value or an out-of-range number throws + an `ArgumentException` from the `Try` form as from the throwing form, before the activation check and before any + Cheat Engine call; it is never returned as a failure. An ended activation then throws + `CheatEngineActivationExpiredException` and a stopping one `CheatEngineInvalidStateException`, under the operation's + own name, before any refusal of the request is returned. +- **Try forms.** No `Try` form lets a CheatEngine.SDK exception escape: an SDK fault raised by Client-internal work + becomes a `CheatEngineFailure` chosen by exception type and by the SDK's failure category. Exceptions from your own + callbacks, codecs, Lua operations and Lua result mappers are rethrown unchanged. A Lua admission that the Client asks + for and CheatEngine.SDK refuses is `ActivationExpired`, `RuntimeChanged`, `InvalidState` or `IndeterminateHostResult` + with `NotStarted`. +- **Classification.** `CheatEngineFailure.Operation` is `.` (for example `Memory.ReadPrimitive`, or + `Client.Activate` for the activation itself), and the `default` failure is safe to read. An outcome or status of + CheatEngine.SDK that the Client does not recognize fails closed as `IndeterminateHostResult`, never as a success. A + host rollback or release that was not confirmed is reported with `CleanupUnconfirmed`, under + `IndeterminateHostResult` or the kind of the failure that caused it. An unavailable Address List or inspection global + is `CapabilityUnavailable` with `NotStarted` in every lookup, and a record activation refused by its callback, script + or record type is `OperationRejected` with `Started`. +- **Leases.** A lease no longer throws from `Dispose()`: `Release()` returns the outcome, and a repeated release returns + the outcome that ended the lease without calling Cheat Engine. `RequiresManualRecovery` becomes `true` once a release + attempt leaves the resource behind; `IAutoAssemblerPatchLease.CanDisable` is the earlier signal. Core and Hosting + attempt every cleanup in reverse order and report every failure: one failure is rethrown as the same instance, + several are aggregated, and Hosting logs each failed stage. A failed Address List record creation destroys the + partial record once and reports `CleanupUnconfirmed` when that rollback is not confirmed. +- **Target selection.** `IProcessClient.Attach` goes through CheatEngine.SDK's `SelectAndObserve` and reads the + selected process again before it reports success; a file opened as a process is `TargetIdentityUnavailable`. A + target-bound lease ends when the Client observes that Cheat Engine selected another process, and that release is + refused (`RefusedTargetChanged`) without freeing anything: release target-bound leases before selecting another + process. +- **AOB scans.** One scope rule applies on every route: a match lies wholly inside the module and starts inside the + range. Every route copies at most 65,535 addresses, and `MaximumResults`, `Take`, `FirstOrNone` and `RequireSingle` + bound only that copy, never Cheat Engine's scan. A global scan for which Cheat Engine returns no result list fails + with `IndeterminateHostResult` instead of `OperationRejected`: the global route calls CheatEngine.SDK's + `AobScanner.TryScanOutcome`, and on Cheat Engine 7.7 `AOBScan` returns `nil` for zero matches and for some host + failures alike, so it is never reported as `null` or `NotFound`. A result list or MemScan session is released exactly + once on every path; when that release is not confirmed, the copy is discarded and the scan fails with + `IndeterminateHostResult`, or the kind of the failure that caused it, and `CleanupUnconfirmed`. `IsTruncated` means + that the copy is not proven complete, and `RequireSingle` reports a single match that is not proven unique as + `IndeterminateHostResult`, not `AmbiguousMatch`. +- **Memory.** `IMemoryClient` never resolves a codec: pass the `IMemoryCodec` in a `MemoryReadRequest` or + `MemoryWriteRequest`. Primitives take `where T : unmanaged` and support the 8- to 64-bit integers, `float`, + `double` and `Address`; any other `T` is `OperationRejected` with `NotStarted`, without a Cheat Engine call. Pointers + follow the observed process width, never the configured size: an unknown or mismatched width is refused before any + access, and nothing is truncated on a 32-bit target. String requests carry an explicit `MemoryStringEncoding`. +- **Implementable interfaces.** `IMemoryCodec.TryRead` and `TryWrite` report an `out CheatEngineFailure failure`, + and their contexts report the same on `TryReadBytes` and `TryWriteBytes`. `ILuaModule` carries its `Descriptor`, and + `Unregister()` returns a `LuaModuleReleaseOutcome`. These interfaces are frozen for 1.x. +- **Operations renamed or reshaped.** `ICheatEngineClient.Scans` is `ValueScans`; `IProcessClient.GetCurrent` is + `GetCurrentProcess`; `ITableClient.GetRecord(int)` is `GetRecordAt`, `GetSelected` is `GetSelectedRecord`, + `TrySelect` is `TrySelectRecord` and `Update(update)` is `Update(id, update)`; `IAssemblyClient.GetInstructionSize` + is `GetInstructionLength` and `GetPreviousInstruction` is `GetPreviousInstructionAddress`, and `ApplyPatch` moved to + `IAutoAssemblerClient`; `TargetAllocationRequest` is `AllocationRequest` and `TargetAllocationAccess` is + `AllocationProtection`. Value-scan sessions take `ValueScanFirstRequest` and `ValueScanNextRequest` (`TryFirstScan`, + `TryNextScan`), and `TryResolveAddress` an `AddressResolutionMode`, instead of CheatEngine.SDK request and option + types. `ICheatEngineDispatcher` has explicit overloads instead of an optional token, and `ILuaClient` one + `TryExecute(in TOperation ...)` design. +- **Facts renamed or regrouped.** `CheatEngineRuntimePlatformInfo.SystemArchitecture` is `HostArchitecture` and + `TargetPointerSize` is `TargetBitness`; `ProcessSnapshot.TargetArchitecture` is `Architecture` and + `TargetPointerSize` is `Bitness`; `IMemoryReadContext.PointerSize` gives way to `Bitness` and the configured pointer + size. The runtime snapshot groups its facts in `Version` and `Platform`, and its `ClientCapabilities` is + `Capabilities`; `CheatEngineRuntimeVersionInfo.ObservedCheatEngineVersion`, a `double?`, is the four-part + `CheatEngineVersion`. `MemoryRecordSnapshot` keeps a record's facts only in `Content` and `State`, without their + top-level copies. The batch outcomes report `RequestedCount`, `IsSuccess`, `Failure` and `Values` instead of + `AttemptedCount`, `Succeeded`, `Cause` and `ReadPrefix`, and `MemoryBatchLimits.MaximumOperations` is + `MaximumOperationCount`. The local process catalog types are `LocalProcessEnumerationRequest`, + `LocalProcessEnumerationResult` and `LocalProcessSnapshot`, formerly `ProcessEnumerationRequest`, + `ProcessEnumerationResult` and `ProcessInfoSnapshot`. +- **Enum values.** Every public enum is an `int` enum with explicit values, and every outcome enum has `Unknown = 0`. + This shifts the values of `ClientCapabilityEvidenceReasonCode`, `MemoryBatchWriteEffectState` (whose `Complete` is + `Completed`) and `ValueScanSessionState` (whose `Disposed` is `Closed`). +- **Capabilities that were contracts.** Value scans, target allocations and instructions, which always reported + `CapabilityUnavailable` before 1.0.0, are operational adapters; **Added** gives their experimental ids. +- **Lua.** A Lua module or export name that the activation already reserved is refused with `OperationRejected`, and + a generated module whose registration is refused throws the exception of its failure's kind. +- **Composition.** `CheatEngineClientOptions.AllowedTableRoots` is an `IList` and `MemoryResourceLimits` is + never `null`; neither has a setter. Configure the plugin's own files against `builder.PluginDirectory`, not + `AppContext.BaseDirectory`, which is the folder of the .NET host inside Cheat Engine. +- **Fluent.** The builders are plain `readonly struct`s without equality, each bound once to the service that created + it. +- **Template.** The Lua status global of a generated plugin is derived from its project name instead of the fixed + `cheatengine_client_plugin_status`. + +### Removed + +- **Domains that no CheatEngine.SDK primitive backs:** timers, hotkeys, the debugger and breakpoints, the speed hack, + target-memory and file hashing, DBVM, remote execution and DLL injection, with the event streams they used: the + `Timers`, `Hotkeys`, `Debugger`, `Speed`, `Hashing`, `Dbvm`, `RemoteExecution` and `Events` namespaces, their + `ICheatEngineClient` properties and their capability ids. None of them had an implementation: each always reported + `CapabilityUnavailable`. `IProcessClient.Pause`, `ResumeExecution`, `GetPauseState`, `Create` and + `AttachForeground`, and `IAssemblyClient.GetComment`, are removed for the same reason. The + [ROADMAP](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/ROADMAP.md) lists what each one waits for. +- **Companion interfaces and shims:** `IMemoryBatchClient` (merged into `IMemoryClient`), `IDescribedLuaModule` (merged + into `ILuaModule`), `ILocalProcessDiagnostics` (replaced by `IProcessClient.TryGetLocalProcesses`), + `CheatEngineClientBuilder.AddMemoryCodec` and the implicit codec resolution, `ITableClient.GetCurrent` (use + `GetSnapshot`), `ICheatEngineRuntime.GetSdkCapability` and `CheatEngineRuntimeSnapshot.SdkCapabilities`, + `ClientCapabilityEvidence.IsExecutable` (read `AvailabilityState`), `ILuaModuleLease.Epoch`, the `ILuaClient` + overloads that took an `ILuaOperation` interface, and the `MemoryStringReadRequest.Create` and + `MemoryStringWriteRequest.CreateBounded` factories with their `WideCharacter` flag. +- **Public constructors that bypassed the contracts:** those of every Client exception, of the options validators + (`CheatEngineClientOptionsSemanticValidator`, `ValidateCheatEngineClientOptions`, now internal), and the constructor + and `BuildServiceProvider` of `CheatEnginePluginBuilder`. +- **Fluent helpers:** the static `Memory` entry class, `Using(memory)` and the overloads that took an `IMemoryClient`, + `ReadUtf8`, `ReadUtf16`, `WriteUtf8` and `WriteUtf16` (use `ReadString` and `WriteString` with a + `MemoryStringEncoding`), `ReadableExecutable()`, `WithProtectionFlags(string)` and the CheatEngine.SDK + `AobScanOptions` of `AobScanBuilder.Options`. + +### Security + +- **Opt-ins.** `IUnsafeLuaClient` exists only after `EnableUnsafeLuaExecution()`, and `IAutoAssemblerClient` only + after `EnableAutoAssemblerPatches()`: without its opt-in, each capability reports `Unavailable` and every call is + refused with `CapabilityUnavailable` and `NotStarted` before any Cheat Engine call (scenario Q44). Table files need an + allowed root: without one they are `CapabilityUnavailable`, and a path outside every `AllowedTableRoots` entry is + `OperationRejected`. +- **Registrations.** `IInspectionClient.TryRegisterSymbol` refuses a name that already resolves (a registered symbol, + a module or an address expression) with `OperationRejected` and `NotStarted`, and a symbol or Lua module release + never removes a name that another owner replaced. +- **SDK range.** A plugin that references `CheatEngine.SDK` 3.0 or later directly next to this Client fails to build + with `CECLIENT017`. `CheatEngineClientAllowUnsupportedSdk=true` turns the error into a warning; such a plugin is + unsupported and is expected to break at run time. A direct reference below 2.0.0 fails the restore with `NU1605`. +- **Log redaction (Q46).** No Client logging event carries an address, value, expression, path, script, message, + exception or failure object, and a test rejects any event whose parameters could. `CheatEngineFailure.ToString()` + returns only the kind, the operation and the host effect, and `LeaseReleaseOutcome.ToString()` only the kind and the + effect. The host log provider writes an entry's message template and exception type name, never its placeholder + values or exception message, unless `IncludeFormattedMessages` is set. A message built by string interpolation, such + as `logger.LogInformation($"Read {address}")`, is its own template and carries its values: log constant structured + templates or `LoggerMessage` methods. The `ceplugin` template logs a failure's kind, operation and host effect only. + +### Deployment + +- **CheatEngine.SDK 2.0.0.** The Client consumes exactly `CheatEngine.SDK` 2.0.0 and declares `[2.0.0, 3.0.0)`. The + pin has a single source, `eng/CheatEngineSdk.props`, and the build refuses a pin outside 2.x or a prerelease pin + (`CHEATENGINECLIENT9016`). The loaded CheatEngine.SDK passes the package gate when it has the same major at or above + the pin. Core embeds the consumed package's version, source commit and content hash at build time and fails a build + that cannot (`CHEATENGINECLIENT9050`). No experimental CheatEngine.SDK API is used. +- **CheatEngine.SDK values in the public API.** `Address`, `PointerSize`, `ModuleInfo` and the other descriptive + CheatEngine.SDK 2.x values the charter lists appear in public signatures, so a CheatEngine.SDK 3.x means a Client 2.0, + never a 1.x release. +- **Seven packages in lockstep.** The seven packages ship with one version, and each depends on the Client packages it + builds on at exactly that version (`[X.Y.Z]`, `CHEATENGINECLIENT9019`). `CheatEngine.Client.Core` has no public API + and is published only as a dependency of `CheatEngine.Client.Extensions.DependencyInjection`. +- **Versioning.** Every package is versioned by MinVer from `v*` tags: untagged builds are `1.0.0-alpha.0.`, + and the assembly version carries the major number only (`1.0.0.0`). +- **Package contents.** Every package embeds an SPDX 2.2 software bill of materials at + `_manifest/spdx_2.2/manifest.spdx.json`, has its own description, and links the project, the license and this + changelog; Source Link comes from the .NET SDK. The libraries are trim- and AOT-compatible and verify every + referenced assembly, and a Native AOT probe publishes the whole Client graph and calls every Fluent member. Cheat + Engine still loads the framework-dependent managed plugin folder. +- **Template.** The `ceplugin` template references the exact `CheatEngine.Client` version it was packed with and the + pinned `CheatEngine.SDK`, is validated when it is built, and its instances restore with a lock file. The lock also + records the `Microsoft.NET.ILLink.Tasks` version that the .NET SDK bundles, so pin the .NET SDK or regenerate the + lock after an SDK update. +- **Release chain.** `release.yml` releases the seven packages from a `v*` tag: it verifies the tag and takes the + release notes from this file, builds and tests the tag commit, stages the SBOMs and `SHA256SUMS`, attests the build + provenance and the SBOMs, drafts the GitHub release, publishes to nuget.org through trusted publishing from the + `nuget` environment after checking every package against `SHA256SUMS`, verifies the publication and only then + publishes the GitHub release. A manual dispatch is a dry run. +- **Continuous integration.** CI builds and tests every change in Debug and Release with exactly .NET SDK 10.0.401 and + locked restores, tests the packages that its Release leg packs (and that a release publishes) instead of packing + them again, and requires the lock-file guard, the NuGet audit policy (high and critical advisories fail every + build), formatting and the workflow security checks to pass before `CI / Gate` does. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..54b7bbb --- /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 new file mode 100644 index 0000000..cd3dfb3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,510 @@ +# Contributing to CheatEngine.Client + +Thanks for contributing. CheatEngine.Client is a Windows x64, .NET 10 layer for in-process Cheat Engine 7.7 plugins, +built on the `CheatEngine.SDK` package. Keep changes focused, preserve existing patterns, and update the documentation +and tests that describe the behavior you change. + +## Prerequisites + +- Windows x64 +- .NET SDK 10.0.401 exactly, as pinned in [`global.json`](global.json). `rollForward` is `disable`, so any other + SDK, a newer one included, stops the build with the install command: + `winget install Microsoft.DotNet.SDK.10 --version 10.0.401` +- PowerShell 7.4 or later, to run the CI checks locally (actionlint, zizmor, the lock-file check) +- Git + +## Build and test + +Run these commands from the repository root before every commit. Restores are locked: they fail when a project's +dependencies no longer match its committed `packages.lock.json`. + +```powershell +dotnet restore CheatEngine.Client.slnx --locked-mode +dotnet build CheatEngine.Client.slnx -c Debug --no-restore +dotnet test --solution CheatEngine.Client.slnx -c Debug --no-build --fail-skips on --filter-not-trait "Category=PackageConsumption" --filter-not-trait "Category=LiveQualification" +dotnet build CheatEngine.Client.slnx -c Release --no-restore +``` + +The test command is the one of the CI Debug leg: it runs every test except the package consumption tests +(`Category=PackageConsumption`) and the live qualification tests (`Category=LiveQualification`); the Release build +compiles the configuration that is packed. A skipped test fails the run: an environment-dependent test is fixed or +deleted, never skipped. The live qualification tests start a sandboxed Cheat Engine, so every command here, like CI, +excludes them by trait; run without that filter, they fail with the instructions of their opt-in (see +[Live qualification](#live-qualification)). + +The package consumption tests consume the exact packages you pack, as the CI Release leg does. Run them as well when a +change reaches what is packed: a project file, `eng/`, `Directory.Build.*`, `Directory.Packages.props`, `templates/`, +a packed README, or a public API that the template or a README snippet uses. The Native AOT probe completes the set: + +```powershell +Remove-Item artifacts/nuget -Recurse -Force -ErrorAction SilentlyContinue +dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget +$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path artifacts/nuget).Path +dotnet test --solution CheatEngine.Client.slnx -c Release --no-build --fail-skips on --filter-not-trait "Category=LiveQualification" +Remove-Item Env:CHEATENGINE_CLIENT_PACKAGE_SOURCE +dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj -c Release --no-restore -o artifacts/aot-probe +./artifacts/aot-probe/CheatEngine.Client.AotProbe.exe +``` + +Without `CHEATENGINE_CLIENT_PACKAGE_SOURCE`, the package consumption tests pack the repository themselves, which is +slower. + +They include the template smoke tests: they install the packed `CheatEngine.Client.Templates` package in an isolated +template home, run `dotnet new ceplugin --dry-run`, instantiate the template into a temporary directory outside the +repository, restore it against the packed Client packages and nuget.org only, and build it in Release configuration. +They check that the generated project references the co-packed `CheatEngine.Client` version and the pinned +`CheatEngine.SDK` directly, and that the build output holds the complete deployment closure next to the plugin. They +also check that the package carries the `.gitignore` and no lock file, that the restore's lock file records the pinned +`CheatEngine.SDK` content hash and the SDK-added `Microsoft.NET.ILLink.Tasks`, that `--no-restore` leaves the project +unrestored, which plugin name and Lua global each of a set of project names derives (two names that derive the same +global included), that `--name` without `--output` creates the name's folder, and that an instance builds with warnings +as errors under this repository's `.editorconfig`, code style enforcement and analysis level. To run only the project +that holds them, after the pack above: + +```powershell +$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path artifacts/nuget).Path +dotnet test --project tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj -c Release --no-build --fail-skips on --filter-not-trait "Category=LiveQualification" +Remove-Item Env:CHEATENGINE_CLIENT_PACKAGE_SOURCE +``` + +### Lock files + +Never edit a `packages.lock.json` by hand, and never let an IDE restore rewrite them. After a dependency change, +regenerate the affected projects one at a time, on Windows, with the SDK of `global.json`, no IDE open on the working +tree: `dotnet restore --force-evaluate`. Restore the three live-plugin coexistence fixtures first if they are +affected: they stay outside Central Package Management and keep version 1 lock files, and a solution-level +`--force-evaluate` restore rewrites them into the Central Package Management shape. Never run +`dotnet restore CheatEngine.Client.slnx --force-evaluate` for that reason. + +Commit the regenerated files on their own, as `Regenerate lock files after `. On a rebase conflict in a lock +file, take either side and regenerate it again; never merge a lock file by hand. The `Lock files` CI job runs +`dotnet restore CheatEngine.Client.slnx --locked-mode`: it is the real guard, because `--locked-mode` fails outright +when a committed `packages.lock.json` no longer matches its project graph, and does not notice a hand-edited resolved +version. + +### The consumed CheatEngine.SDK + +The Client consumes exactly one `CheatEngine.SDK` package, pinned in the single reviewed source +[`eng/CheatEngineSdk.props`](eng/CheatEngineSdk.props): never write an SDK version literal anywhere else. There is no +script for the bump; the props file's own header documents the procedure, all of it in one pull request: update +`CheatEngineSdkVersion`, update the reviewed identity literals it names +(`tests/CheatEngine.Client.Tests/Packaging/PackagedClientFeedFixture.cs`, +`tests/CheatEngine.Client.Repository.Tests/LockFiles/LockFileTests.cs`) and the identity the three install guides state +to the new package's hashes, regenerate every `packages.lock.json` (coexistence fixtures first, one project at a time), +and update the SDK version named in prose (`SdkPinTests.ProseMentionsOfTheConsumedSdkEqualThePin` lists the files). +`CHEATENGINECLIENT9016`, `9017` and `CECLIENT017` guard the pin at build and consumption time. Moving to another SDK +major is a migration of the Client, not a dependency bump: the props file's "Major migration" checklist lists what else +it changes. + +### NuGet audit and build guards + +NuGet audits every package, direct and transitive, from the `low` severity up: + +- high (`NU1903`) and critical (`NU1904`) advisories fail every restore and build; +- low and moderate advisories (`NU1901`, `NU1902`), an unavailable audit source (`NU1900`) and `NU1905` stay warnings in + ordinary builds; +- `-p:AuditPipeline=true` turns every audit code into an error, for a stricter local or ad hoc CI run. + +To accept one advisory, add `` with a comment that gives the +justification and an expiry date. Never suppress an advisory on the release path. Never lower the policy itself: +`CHEATENGINECLIENT9030` refuses an analysis level other than the pinned `10.0-recommended`, `CHEATENGINECLIENT9031` +refuses a weakened audit (off, a mode other than `all`, a level other than `low`, or `NU1903`/`NU1904` in `NoWarn` or +`WarningsNotAsErrors`), and `CHEATENGINECLIENT9032` refuses a project without a lock file or outside Central Package +Management (the coexistence fixtures excepted). + +## 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` through a job named `CI`. There is no merge queue. One check is required on a pull request, and no +other: + +- `CI / Gate` requires every other `ci.yml` job to succeed. The only exception is `Sonar`: it must succeed when the + analysis is expected (`SONAR_EXPECTED`: a pull request from this repository, a push to `main`, a manual run) and must be + skipped otherwise (pull requests from forks or Dependabot, which receive no secrets, and the release run). A Sonar run + that was not expected fails the Gate too: it means that the job condition and the Gate's copy of it drifted apart. The + Gate's job summary lists every job with its result, the required result and the reason. + +Drafts do not run CI until they are marked ready for review, so `CI / Gate` stays pending. CodeRabbit's automatic +review still checks the pull request title and the changelog rule of [Pull request conventions](#pull-request-conventions) +on every push, including on drafts, but it is advisory: it never blocks a merge (see [`.coderabbit.yaml`](.coderabbit.yaml)). + +| Check (`CI / ...`) | Runner | What it does | +|--------------------------------|----------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `Build and test (Debug)` | `windows-2025` | Locked restore, build, one `dotnet test --solution` run with coverage (package consumption and live qualification tests excluded by trait) | +| `Build and test (Release)` | `windows-2025` | Locked restore, build, pack (exact package set, embedded SBOM), benchmark discovery, one test run against the packed packages (live qualification tests excluded by trait) | +| `Native AOT publication probe` | `windows-2025` | Publishes and runs `tests/CheatEngine.Client.AotProbe`: trim and Native AOT compatibility of the Client graph, not a Cheat Engine load | +| `Sonar / Analyze` | `windows-2025` | SonarQube Cloud CI-based analysis with the Debug coverage; waits for the quality gate except on pushes to `main` | +| `Lint` | `ubuntu-24.04` | actionlint and the offline zizmor audits over the workflows | +| `Format` | `ubuntu-24.04` | `dotnet format whitespace . --folder --verify-no-changes --exclude artifacts` | +| `Dependency review` | `ubuntu-24.04` | On pull requests, reviews dependency changes against `.github/dependency-review-config.yml`; a notice on other events | +| `Lock files` | `windows-2025` | `dotnet restore CheatEngine.Client.slnx --locked-mode` | +| `Gate` | `ubuntu-24.04` | The required check described above | + +A test module that shows no activity for 15 minutes is dumped and fails; on failure, `build-test` uploads the hang and +crash dumps and the binary logs. No CI job uses a NuGet cache, because the release run reaches every job, and every job +that runs `dotnet` goes through `.github/actions/setup-dotnet`, which restores with `--locked-mode`. SonarQube Cloud runs +as CI-based analysis only; Automatic Analysis stays off for the project, otherwise the scanner fails at `begin`. + +`WorkflowContractTests` (in `tests/CheatEngine.Client.Repository.Tests/Workflows`) freezes these rules. It fails on a +`ci.yml` job missing from the Gate's `needs`, a Sonar condition that differs from the Gate's `SONAR_EXPECTED`, an action +not pinned to a full commit SHA with a version comment, a checkout that keeps credentials, a runner label other than +`windows-2025` or `ubuntu-24.04`, a job without a timeout, a package cache on a workflow the release reaches, a +`pull_request_target` or `merge_group` trigger, a path filter on the required workflows, or an artifact name outside the +reserved list. + +These workflows are advisory, never required: CodeQL, OpenSSF Scorecard, the online zizmor audits and the NuGet +dependency snapshot submission (`Dependency submission`, which runs the GitHub Component Detection action against the +locked restore). + +### Run the CI checks locally + +The build already enforces formatting, compiler and analyzer rules. The remaining checks run from the repository root; +actionlint 1.7.12 and zizmor 1.30.1 are the versions CI pins: + +```powershell +dotnet format whitespace . --folder --verify-no-changes --exclude artifacts +actionlint +zizmor --offline .github +dotnet restore CheatEngine.Client.slnx --locked-mode +``` + +### Runner labels + +Jobs run on `windows-2025` and `ubuntu-24.04`, never on a moving `-latest` label, and Dependabot does not update +`runs-on`. To move to a new runner image, change the label in every workflow and in `PinnedRunners` of +`WorkflowContractTests`, in one pull request. + +### Flaky tests + +No required run retries a test, and `--fail-skips on` rules out skipping one. A flaky test is fixed or deleted in the +pull request that finds it; there is no scheduled job that re-runs threading-sensitive tests outside a normal CI run. + +## Style and analyzers + +- Follow [`.editorconfig`](.editorconfig): tab-indented C#, two-space project and configuration files. Builds treat + formatting, compiler and analyzer diagnostics as errors; run `dotnet format` on the projects you touch. A commit that + only reformats or mechanically renames code is listed in [`.git-blame-ignore-revs`](.git-blame-ignore-revs), and the + pull request that contains it is merged with a merge commit ([Merge policy](#merge-policy)). +- Use file-scoped namespaces, explicit types instead of `var`, braces, explicit accessibility and `_camelCase` private + fields. Constants and `static readonly` fields are PascalCase at every accessibility, and async methods end with + `Async`. Test names are PascalCase sentences; async tests keep the `Async` suffix. +- Public APIs require XML documentation. A public API change in Abstractions, Fluent, Hosting or the DI extensions is + declared in that project's `PublicAPI.Unshipped.txt` (RS0016/RS0017 are errors); Core has no public API and the + `CheatEngine.Client` facade ships no assembly. `PublicAPI.Shipped.txt` changes only in a release pull request, which + moves `Unshipped` to `Shipped` once, as its last API commit ([RELEASING](RELEASING.md#prepare-a-release)). 1.0.0 is + the first release: its pull request (#59) cleared every `PublicAPI.Shipped.txt` when it started, so no `*REMOVED*` + entry exists for an API that never shipped. From 1.0.0 on, the 1.x rules of the + [README](README.md#versioning-and-compatibility) apply: a stable public API is never removed or changed before 2.0. +- An experimental API carries `[Experimental("CECLIENT500x")]`, whose `UrlFormat` points to its section of the + Abstractions README, and its PublicAPI lines keep the `[CECLIENT500x]` prefix (`ClientExperimentalDiagnosticsTests`). + It leaves experimental only when committed host evidence covers every scenario of its capability + ([RELEASING](RELEASING.md#qualification-gate)). +- Core implementation types stay `internal sealed`, and every Cheat Engine interaction goes through the Client's + dispatcher and ports; the SDK remains the only native authority. +- `InternalsVisibleTo` grants serve the package graph and this repository only: every project grants `.Tests` + ([`Directory.Build.props`](Directory.Build.props)), Core grants Extensions.DependencyInjection, Hosting and + `CheatEngine.Client.Benchmarks`, and Extensions.DependencyInjection grants Hosting and + `CheatEngine.Client.Hosting.Tests`. The Client assemblies are not strong-named, so a grant names an assembly, not a + signing key, and any assembly with that name sees the internals. Internal members are never a contract: they change + in any release, and the Core and Extensions.DependencyInjection READMEs say so. Add a grant only for a Client package + that composes another one, or for a test or benchmark project of this repository. +- The architecture ratchet in `tests/CheatEngine.Client.Tests/Architecture` freezes the Client's remaining ADR-01 debt: + its direct Lua and owner usages. The Client binds no Lua global itself, and that list stays empty. Shrinking a list + is always allowed. Growing it requires a registered exception, in the ratchet itself, with its reason and the name of + the CheatEngine.SDK primitive that is missing (`AwaitingSdkPrimitive`, like the `MemoryRecord` child-count getter); + an exception that names no missing SDK primitive is not accepted, and a new need goes to the CheatEngine.SDK + repository first. The only permanent entries are the unsafe Lua opt-in's (`UnsafeLuaClient`). The typed SDK Lua API + the Client uses is an exact, reasoned inventory of its own, and no Client code references or suppresses an + `[Experimental]` SDK member (`CESDK5xxx`). + +## Package READMEs and implementation notes + +The README next to each packed project is its nuget.org page, written for plugin authors: installation, requirements, +what the package offers and its contracts, with absolute `https://` links only (`PackedReadmesContainNoRelativeLinks`). +What only a contributor needs lives here instead. + +Every C# block of a packed README and of the root README is labelled `csharp` and is a whole file (usings, namespace, +types): `ReadmeSnippetCompilationTests` compiles the blocks of each README as one plugin project against the packed +Client, with warnings as errors, so run the package consumption tests when you change one. A block that cannot compile +on purpose is labelled `csharp nocompile`, and the line right above its opening fence says why: +``. A C# block labelled `cs` or `c#` fails the test instead of escaping it. + +A `PackageReference` that a README writes names the version the snippets compile against: `X.Y.Z` for +`CheatEngine.Client`, the pinned version for `CheatEngine.SDK`, and the version of the template project for +`Microsoft.Extensions.Configuration.Json`. When a dependency update moves the template's version, update the READMEs +that write it (`EveryDocumentedPackageReferenceNamesTheCompiledVersion` names each stale line). + +### Changing a package + +Whatever its assembly, a public type lives in the root namespace `CheatEngine.Client` or in a functional namespace +below it. Never declare a type named `CheatEngine` or `Client`: CA1724 matches each namespace segment. + +- **Abstractions:** every change is a public API change. Keep request and value types immutable, preserve the + functional namespaces, add XML documentation, declare the change in `PublicAPI.Unshipped.txt` and add focused + contract tests in `tests/CheatEngine.Client.Abstractions.Tests`. +- **Fluent:** add a fluent surface only when it preserves an existing explicit contract and has a bounded terminal + operation. Never store a Cheat Engine resource in a builder, add a Core dependency or introduce an assembly-derived + namespace; add behavior tests in `tests/CheatEngine.Client.Fluent.Tests` for every public member or terminal + condition. +- **Core:** treat lifetime, dispatch and disposal changes as host-safety changes. Keep SDK handles internal, route new + Cheat Engine work through the dispatcher, add a capability observation for an optional binding, and test target and + activation invalidation and reverse-order cleanup in `tests/CheatEngine.Client.Core.Tests`. Core's public baseline is + intentionally empty: a public type there needs an explicit product-surface decision. The ordinary test suite uses + SDK-facing ports and fakes; it does not replace the opt-in live qualification. +- **CheatEngine.Client:** the package plugins reference is a facade. It packs no assembly (`IncludeBuildOutput` is + `false`) and declares no PublicAPI files; it only brings Fluent and Hosting at exactly its own version. Never add + source to it: a public type belongs to the library that owns its contract. + +### Core internals + +The public behavior below is specified in the Abstractions README; this is how Core implements it. + +#### Capabilities and gates + +Every capability composes one operational adapter, so every implementation gate is satisfied, and +`CapabilityRatchetTests` keeps it that way on the supported SDK major (`_CheatEngineClientSupportedSdkMajor` in +`eng/CheatEngineSdk.props`). The value scans (`CECLIENT5001`) are sessions over CheatEngine.SDK's `MemoryScanSessions` +owners and the target allocations (`CECLIENT5002`) leases over its `TargetMemoryAllocator` regions, released on Cheat +Engine's main thread before the SDK detaches and never through another target. + +Every capability is described once, in the internal `ClientCapabilityCatalog`: its implementation gate, where its +policy and host gates come from, and the live scenarios its qualification gate requires. `RuntimeClient` composes the +snapshot from that catalog, and `CapabilityDocumentationTests` keeps the capability tables of the READMEs in step with +it. The qualification gate comes from the internal `HostQualificationGate`: it is `Satisfied` only when the host +evidence this build embeds (`HostQualificationEvidence`, empty until a live run is recorded, and excluded from the +qualified source digest) names exactly the loaded CheatEngine.SDK package, which must be the reviewed one, Cheat Engine +7.7.0.10621 and the supported host profile, when the observed host is Cheat Engine 7.7.0.10621 64-bit on Windows with a +local target of an architecture the run covered, when the evidence names this Client version, and when every scenario +of the capability passed without a waiver; otherwise it is `Unknown` and names the first condition that does not hold. + +The package gate compares the CheatEngine.SDK identity embedded in the Core assembly at build time (version, source +commit and NuGet content hash, as `AssemblyMetadata`, taken from the locked and restored package, and the supported +major of `eng/CheatEngineSdk.props`) with the informational version of the `CheatEngine.SDK.Engine` assembly actually +loaded; it reads assembly attributes only. Its reason says whether the loaded assembly is exactly the reviewed package, +a distinction the qualification gate needs (a receipt covers only the tuple it was produced with). A build that cannot +embed that identity fails with `CHEATENGINECLIENT9050`. + +**Instructions** (`CECLIENT5003`): the internal `AssemblyClient` runs each call in one dispatched callback behind one +`LuaAdmission`, observes the instruction profile once (`InstructionProfiles.TryObserveCurrent`), refuses an address +wider than that profile before any instruction function of Cheat Engine is called, and passes the same profile to +`SdkInstructionPort` (`InstructionAssembler`, `InstructionDisassembler`, `InstructionNavigator` and the counted +`TargetMemory.TryReadBytes`). Assembly uses a 16-byte buffer bounded by `MemoryResourceLimits.MaximumReadBytes`, with +one retry at the exact length the SDK reports, and refuses an empty result; a disassembly reads its bytes from target +memory for the reported length, between two SDK target checks, and never parses the disassembler's byte column. +`InstructionMapping` maps every `InstructionOperationStatus` totally, and a step that follows an earlier instruction +call of the same Client call is never `NotStarted`. + +**Auto Assembler patches** (`CECLIENT5004`): the internal `AutoAssemblerClient` is registered only by +`EnableAutoAssemblerPatches()`, which also sets `CoreClientPolicy.EnableAutoAssemblerPatches`, and it refuses every call +without that policy (`CapabilityUnavailable`, `NotStarted`, no dispatch). Its only Cheat Engine calls go through +`SdkAutoAssemblerPort` (`AutoAssemblerPatcher.TryApplyWithOutcome` and `TryCheck` with bounded options, behind +`LuaAdmission`); `AutoAssemblerMapping` maps every SDK outcome category totally. The applied patch is handed to +`AutoAssemblerPatchLease`, a target-bound `HostResourceLease` registered inside the same dispatched callback under the +selection of the process incarnation the SDK bound the patch to (`ITargetSelectionBinder`, as for allocations and value +scans), which releases through the SDK owner's `ReleaseWithTargetOutcome` and never rebuilds a `[DISABLE]` section; a +refused registration is reported by `LeaseRegistration`. That owner consumes its disable information on every release +status, so `AutoAssemblerMapping.ToReleaseOutcome` reports `NotInvoked` as `RefusedRuntimeChanged` (manual recovery) +instead of the shared retryable `CleanupUnavailable`. + +#### Runtime facts, target selection and pointer width + +Every runtime and target fact is a read-only CheatEngine.SDK call through `SdkRuntimeObservationPort`: +`RuntimeObservations.TryObserveRuntimeInfo` for the snapshot, `RuntimeHostOperations` for the host facts, +`RuntimeProcessOperations` (`ObserveCurrent`, `ObserveTargetArchitecture`, `TryGetConfiguredPointerSize`) for the target +and `TargetSelection` (`ObserveCurrent`, `ValidateCurrent`) for its identity; the architecture ratchet keeps the port +to that exact read-only list (Q45). The SDK reads the selected process identifier before and after the target facts and +reads none of them when no target, or a file opened as a process, is selected. The SDK reports every outcome as a +status, never through Lua error text, and `RuntimeObservationMapping` maps each status value explicitly. When the +aggregate snapshot cannot be produced, the Client reads the host facts on their own, and each fact alone when that +fails, and observes the target through `TargetArchitectureObserver`. A target fact that raises or is malformed narrows +the observation to the PID, the bitness and the configured pointer size, which it keeps only when two selected-PID +reads agree; the ISA, the backend and the ABI then stay `Unknown`. + +The one call that changes Cheat Engine's selection is `RuntimeProcessOperations.SelectAndObserve`, behind +`SdkProcessSelectionPort`, and `ProcessClient.TryAttach` is its only caller (architecture ratchet). A refused attach +keeps the SDK status as its kind with an `Unknown` host effect, and the selection is observed again after a refusal, so +the epoch follows what Cheat Engine now selects. For a local process the selection identity is the PID and its +incarnation, the creation time CheatEngine.SDK observed together with the local backend; a known incarnation is checked +with `TargetSelection.ValidateCurrent`. `ProcessClient` advances the target-selection epoch, and ends the target-bound +leases of the earlier selection, when the PID changes, when the same PID denotes another incarnation, when a known +backend, ISA or process width changes to another known value, or when Cheat Engine reports no target or a file opened +as a process; a fact that is transiently unknown keeps the epoch and the last known value. + +Every pointer read and write goes through the width-qualified `TargetMemory.TryReadPointer` and `TryWritePointer` +overloads with the observed bitness, and every `MemoryAccessFailure` reaches the caller through +`MemoryAccessFailureMapping`, value by value and never as text. + +#### Address List records and symbols + +Every trusted table load that reaches Cheat Engine advances the table generation of the activation inside the +dispatched load; each snapshot is judged by the generation read when it was copied. Table files load and save through +CheatEngine.SDK's `CheatTableFiles`, only after the `AllowedTableRoots` policy admitted the path, and `TableMapping` +classifies its `LuaOperationStatus` value by value; the Client binds no Cheat Engine global itself. Delete, parent +assignment and activation are CheatEngine.SDK `AddressListMutations` commands (`Delete`, `SetParent` with the Client's +explicit traversal limit of 4096 records, `SetActive`), also classified by `TableMapping`; the record is copied again +after a completed command and a failed copy is never merged with the command's result. Snapshots read the record +through CheatEngine.SDK's typed `MemoryRecord` getters (`TryGetActive`, `TryGetAsync`, `TryGetAsyncProcessing`, +`TryGetScript`, `TryGetOffsetCount`); the child count alone is still Cheat Engine's `Count` property, because the SDK +has no child-count getter and `TryGetChild` cannot tell a missing child from a failed read (`FrozenLuaUsage`, +`AwaitingSdkPrimitive`). `TryGetScript` cannot tell a record without a script from a failed read either; a getter that +tells them apart is awaited from the SDK. A failed `Create` is rolled back once through `AddressListMutations.Delete`. + +Symbol registration refuses a name that already resolves (`EngineInspection.ResolveAddress`), then registers through +CheatEngine.SDK's ownership coordinator (`SymbolRegistry.TryRegisterOwned`) and registers the lease with the +activation in the same main-thread callback. + +#### AOB scans + +`PatternScanner` runs a request without a module or range as one global Cheat Engine `AOBScan`. A module and/or range +request resolves the module, builds the SDK's `AobScanBounds` (the module intersected with the range, whose inclusive +end becomes `End + pattern length`, checked and saturated) and, when the port's `TargetSelection.ObserveCurrent` +observation qualifies the target, runs the stable `AobScanner.TryScanWithinBounds` overload with a destination of +`min(MaximumResults + 1, ScanResourceLimits.MaximumPatternMatches)` addresses and the caller's token. An unqualified +target, or a session the SDK could not create or attach to one target, falls back to the global scan with the module +and range applied while copying; the fallback never reports a verified target identity. Both routes copy through one +scope predicate (`PatternScanner.IsInsideRequest`), copy at most `ScanResourceLimits.MaximumPatternMatches - 1` +addresses, and send the empty protection text for the default filter. +`tests/CheatEngine.Client.Benchmarks/AobRouteComparisonBenchmarks.cs` compares the Client cost of the two routes over a +fake port, and `PatternScannerMaterializationBenchmarks.cs` measures the copy cost alone; the Cheat Engine scan cost is +a live-host measurement. + +The SDK owner of the result list is handed to the Client wrapper through `OwnershipHandoff`, so a failure between +acquisition and publication releases the Cheat Engine list exactly once; when that release is not confirmed, the typed +`OwnershipHandoffException` carries its kind and the scan fails with `CleanupUnconfirmed`. The scanner then releases +the list exactly once on every path, inside the dispatched callback, through the SDK owner's never-throwing +`ReleaseWithOutcome`, mapped with `SdkReleaseOutcomes`. The AOB port calls `AobScanner.TryScanOutcome` with its target +context, and `AobScanMapping` classifies every outcome from SDK outcome values only, never from Cheat Engine or Lua +error text. + +#### Leases and release outcomes + +Every Client lease derives from the internal `HostResourceLease`, which implements `ICheatEngineLease` once: the +release runs on Cheat Engine's main thread through the activation dispatcher, attempts are serialized and idempotent, +`Dispose` never throws, and each attempt is logged with its operation name, kind and effect only (event 1701). A lease +registers with the activation registry and, when it is bound to the selected target, with the target-selection lifetime +too. A complete outcome unregisters it; a retryable or incomplete one keeps it registered, and the activation drain +retries or reports it (Q43). + +`SdkReleaseOutcomes` maps the CheatEngine.SDK release statuses totally (`TargetReleaseStatus`, +`SymbolRegistrationReleaseKind`; an unknown value is `Unknown` with an unknown effect) and combines the parts of one +lease by keeping the outcome that leaves the most to do. The Lua module lease (`Lua.Release`) maps the kind its module +reports with `LuaModuleReleaseMapping`; `LuaRegistrationReleaseKind` is mapped by the generated Lua registrar, which +owns the SDK registration lease (see the generator README). Every lease release is named `.Release`, and every +failure operation `.` after the public call that produced it (`OperationNameTests`). + +#### SDK boundary and the Try contract + +Every Client-internal CheatEngine.SDK call (ports, `TargetMemory`, `EngineInspection`, `AobScanner`, Address List +access and mutations, `CheatTableFiles`, protected Lua execution) runs behind `SdkBoundary`: an SDK exception becomes a +classified `CheatEngineFailure` by exception type and the SDK's own failure category, never by message text. Client +lifecycle exceptions are never translated, and an SDK fault observed after the activation ended is reported as +`CheatEngineActivationExpiredException`. Lua work that Core runs itself asks for its admission through `LuaAdmission` +(`LuaRuntime.TryAcquireOperationWithOutcome`), and a refusal is classified from the SDK's admission status. A +CheatEngine.SDK call that acquires its own admission (`AddressListMutations`, `CheatTableFiles`, `SymbolRegistry`, +`TargetMemory`, scans) raises a plain `InvalidOperationException` when it is refused, which `SdkBoundary` reports as +`OperationRejected` with `Unknown`, unless the activation ended or the SDK detected an external Lua state reset +(`RuntimeChanged`). Consumer-supplied code (dispatcher callbacks, codecs, typed Lua operations) is never wrapped. + +## Evidence and qualification levels + +State the qualification level of every validation you report: **C0** static contract, **C1** managed tests or test +doubles, **C2** native fixture, **C3** the exact Cheat Engine host with a loaded plugin, **C4** several components (two +plugins, a target switch). A C1 or C2 success is never presented as host qualification, and a Native AOT publication is +never a Cheat Engine load. CI runs static and managed tests only; the Cheat Engine 7.7 x64 live suite is opt-in and never +runs in CI. + +### Live qualification + +The live qualification tests (`tests/CheatEngine.Client.Tests/LiveQualification`, `Category=LiveQualification`) build +plugins from the packed Client packages, load them into a sandboxed copy of Cheat Engine 7.7.0.10621 x64 and drive +disposable gtutorial targets. They run only on a maintainer workstation that opts in, with the operator present, never +in CI (they fail when `CI=true`). The procedure, from the prerequisites and the opt-in to the protection of the user's +Cheat Engine state and the commands, is in +[`tests/CheatEngine.Client.Tests/README.md`](tests/CheatEngine.Client.Tests/README.md#live-qualification); +[RELEASING](RELEASING.md#qualification-gate) says which run a release needs. A result counts only as the committed, +redacted evidence of a run under `tests/CheatEngine.Client.Tests/LiveQualification/Evidence/`, and until that evidence +exists no document claims a host qualification (`QualificationEvidenceTests`). + +## Branches and pull requests + +1. Update your local `main`, then create a focused branch from it. +2. Make the smallest change that solves the problem, with its tests. +3. Run the relevant build, test and pack commands. +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), in one of its four + categories: **Added** (an extension), **Changed** (a semantic correction), **Security** (a hardened refusal) or + **Deployment** (what is built, packed, pinned or published). + +Fill in the pull request template: the problem, the resulting behavior, the validation commands and results with their +qualification level, the API and compatibility impact, and any remaining host-level limitation. Pull requests are +merged once the required checks pass, as the merge policy below says. + +### Merge policy + +- **Squash merge** by default: the pull request title becomes the commit subject on `main`, except for a pull request + with a single commit, whose squash keeps that commit's subject ([Commits](#commits) gives it the same conventions). +- **Merge commit**, never a squash or a rebase, for a pull request that contains a commit listed in + [`.git-blame-ignore-revs`](.git-blame-ignore-revs). A squash or a rebase would give that commit a new SHA on `main`, + the listed SHA would no longer exist there, and `git blame` would stop ignoring the reformat. The 1.0.0 release pull + request (#59) is merged this way. GitHub gives the merge commit its own subject (`Merge pull request #N from ...`) + and repeats the pull request title in its message, so the title conventions below still apply. +- A branch whose commits `.git-blame-ignore-revs` lists is never rebased: later changes are new commits on top of it. + +### Pull request conventions + +No required check enforces these; CodeRabbit's automatic review checks them on every push and flags a miss, but it is +advisory and never blocks a merge. Follow them anyway, since the title becomes the subject of the squash commit on +`main`, or the message of the merge commit: + +- **Title:** an imperative sentence (`Add`, `Fix`, `Keep`...) that starts with an uppercase letter, has at most 72 + characters, no Conventional Commit prefix such as `feat:` and no trailing period. +- **Changelog:** a change under `libs/`, `src/`, `source-generators/` or `templates/` (lock files excepted) needs an entry + under `## [Unreleased]` in `CHANGELOG.md`. When nothing consumer-visible changes, put the line + `` on its own line in the description instead. +- **Dependabot:** its pull requests are exempt. Rename their `chore(deps): ...` squash subject to an imperative sentence + when merging. + +## Dependency updates + +Dependabot opens weekly pull requests for NuGet packages, GitHub Actions (the workflows and `.github/actions`) and the +.NET SDK of `global.json`. A new release waits 7 days (30 for a NuGet major); security updates are not delayed. Minor +and patch updates are grouped, and so are NuGet security updates, except a `CheatEngine.SDK` update, version or +security: it moves the reviewed pin, so it arrives in a pull request of its own and passes only once the bump procedure +of [`eng/CheatEngineSdk.props`](eng/CheatEngineSdk.props) is complete (identity literals, every lock file, prose). The +pin is also the lower bound of the `CheatEngine.SDK` range that Abstractions, Core and Hosting publish, so a release +that moves it raises that bound for every consumer and records it under **Deployment** in `CHANGELOG.md`. Dependabot +ignores `CheatEngine.SDK` majors (a Client migration), the Roslyn packages (they move with the Lua generator's compiler +floor, `CHEATENGINECLIENT9020`), the SDK-implicit ILLink and ILCompiler packages (they move with `global.json`) and .NET +SDK majors ([`.github/dependabot.yml`](.github/dependabot.yml)). + +The `Microsoft.Extensions.*` versions of [`Directory.Packages.props`](Directory.Packages.props) are published floors, +not only build inputs: Extensions.DependencyInjection and Hosting declare the ones they reference as minimum versions +in their nuspecs, every consumer inherits them, and the packed template takes +`Microsoft.Extensions.Configuration.Json` from the same file (`CHEATENGINECLIENT9018`). They all follow the latest +reviewed 10.0.x patch, the .NET major the packages target, and move together only through Dependabot: its grouped +minor-and-patch pull request or a security update, never a hand edit in an unrelated change. A floor is never lowered, +and a released one stays in that release's nuspecs; a consumer that needs a later patch references it directly. +Dependabot also offers `Microsoft.Extensions` majors: take one only together with a move to that .NET major, never as a +dependency bump. + +Dependabot does not regenerate every lock file. When `Lock files` fails on a Dependabot pull request, check the branch +out, run `dotnet restore --force-evaluate` for each affected project, commit `Regenerate lock files after +` and push; Dependabot then stops rebasing that pull request. A .NET SDK update also needs the new version +moved by hand into `sdk.errorMessage` in the same commit. + +## Security + +Report a vulnerability privately, as [`SECURITY.md`](SECURITY.md) describes, never in a public issue, discussion or pull +request. + +## Commits + +Use focused commits with an imperative subject of at most 72 characters, without a Conventional Commit prefix and +without a trailing period, such as `Harden the package consumption smoke tests`. Explain why in the body. Do not add +`Co-authored-by` trailers. + +## Releases + +Versions come from MinVer and the nearest `v*` tag; the seven packages always share one version. Releases are built, +attested and published by `release.yml` through NuGet trusted publishing, only from a tag on `main`. See +[`RELEASING.md`](RELEASING.md). diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index 32b24c0..dcc1356 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -1,60 +1,62 @@ - - - - - - - - + + + + + + + + + + + - - - + + + + - - - - - + + + + + - + - + - - - - - - - - - + + + + + + + + + - - + + - - + + - - + + + + + + + - + diff --git a/Directory.Build.props b/Directory.Build.props index 769878f..e21f995 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,19 +13,39 @@ enable enable true - latest-recommended + + <_CheatEngineClientPinnedAnalysisLevel>10.0-recommended + $(_CheatEngineClientPinnedAnalysisLevel) true true true true + + + true + all + low + <_CheatEngineClientNuGetAuditCodes>NU1900;NU1901;NU1902;NU1903;NU1904;NU1905 + + $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1905 + + $(WarningsAsErrors);$(_CheatEngineClientNuGetAuditCodes) + + - 0.1.0 CheatEngineNet CheatEngineNet + + Copyright (c) 2026 AriusII, ShadowNineX and CheatEngine.Client contributors git https://github.com/CheatEngineNet/CheatEngine.Client + https://github.com/CheatEngineNet/CheatEngine.Client + $(PackageProjectUrl)/blob/main/CHANGELOG.md MIT false README.md @@ -33,6 +53,27 @@ true + + + v + + 1.0 + + $(MinVerMinimumMajorMinor).0 + <_CheatEngineClientRootVersionPrefix>$(VersionPrefix) + + 5.9.0 + + diff --git a/Directory.Build.targets b/Directory.Build.targets index 5c1ef69..6d0d2b8 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -30,6 +30,9 @@ + <_CheatEngineClientSdkRangeReference Include="@(_CheatEngineClientSdkReference)" - Condition="'%(_CheatEngineClientSdkReference.VersionOverride)' == '[1.0.0,2.0.0)'"/> + Condition="'%(_CheatEngineClientSdkReference.VersionOverride)' == '$(CheatEngineSdkVersionRange)'"/> <_CheatEngineClientProjectReferences>@(_CheatEngineClientDirectProjectReference->'%(Filename)') @@ -68,6 +71,256 @@ Text="CheatEngine.Client must reference only CheatEngine.Client.Fluent and CheatEngine.Client.Hosting."/> + Text="SDK-facing published packages must declare CheatEngine.SDK as $(CheatEngineSdkVersionRange), the range of eng/CheatEngineSdk.props."/> + + + + + + <_CheatEngineClientSdkPinMajor>$([System.Text.RegularExpressions.Regex]::Match('$(CheatEngineSdkVersion)', '^[0-9]+').Value) + <_CheatEngineClientSdkPinProblem> + <_CheatEngineClientSdkPinProblem Condition="!$([System.Text.RegularExpressions.Regex]::IsMatch('$(CheatEngineSdkVersion)', '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'))">is not a NuGet version + <_CheatEngineClientSdkPinProblem Condition="'$(_CheatEngineClientSdkPinProblem)' == '' and $(CheatEngineSdkVersion.Contains('-'))">is a prerelease + <_CheatEngineClientSdkPinProblem Condition="'$(_CheatEngineClientSdkPinProblem)' == '' and '$(_CheatEngineClientSdkPinMajor)' != '$(_CheatEngineClientSupportedSdkMajor)'">has major version $(_CheatEngineClientSdkPinMajor), but this Client supports CheatEngine.SDK $(_CheatEngineClientSupportedSdkMajor).x only + <_CheatEngineClientSdkPinProblem Condition="'$(_CheatEngineClientSdkPinProblem)' == '' and !$(CheatEngineSdkVersionRange.StartsWith('[$(CheatEngineSdkVersion),'))">is not the lower bound of the declared range '$(CheatEngineSdkVersionRange)' + <_CheatEngineClientSdkPinMessage>The consumed CheatEngine.SDK version '$(CheatEngineSdkVersion)' $(_CheatEngineClientSdkPinProblem). This Client is built and tested against a stable CheatEngine.SDK $(_CheatEngineClientSupportedSdkMajor).x release and declares '$(CheatEngineSdkVersionRange)'. Change the pin only in eng/CheatEngineSdk.props (see its header comment for the full bump procedure); moving to another major is a deliberate migration, not a dependency bump. + + + + + + + + <_CheatEngineClientSdkFacingLibrary>false + <_CheatEngineClientSdkFacingLibrary Condition="'$(MSBuildProjectName)' == 'CheatEngine.Client.Abstractions' or '$(MSBuildProjectName)' == 'CheatEngine.Client.Core' or '$(MSBuildProjectName)' == 'CheatEngine.Client.Hosting'">true + <_CheatEngineClientExpectedSdkVersion>$(CheatEngineSdkVersion) + <_CheatEngineClientExpectedSdkVersion Condition="'$(CoexistenceSdkPackageVersion)' != ''">$(CoexistenceSdkPackageVersion) + <_CheatEngineClientCentralSdkVersion>@(PackageVersion->WithMetadataValue('Identity', 'CheatEngine.SDK')->'%(Version)') + + + <_CheatEngineClientPinnedSdkReference Remove="@(_CheatEngineClientPinnedSdkReference)"/> + <_CheatEngineClientPinnedSdkReference Include="@(PackageReference)" Condition="'%(Identity)' == 'CheatEngine.SDK'"/> + <_CheatEngineClientMisversionedSdkReference Remove="@(_CheatEngineClientMisversionedSdkReference)"/> + <_CheatEngineClientMisversionedSdkReference Include="@(_CheatEngineClientPinnedSdkReference)" + Condition="('$(ManagePackageVersionsCentrally)' == 'true' and '$(_CheatEngineClientSdkFacingLibrary)' == 'true' and '%(_CheatEngineClientPinnedSdkReference.VersionOverride)' != '$(CheatEngineSdkVersionRange)') or ('$(ManagePackageVersionsCentrally)' == 'true' and '$(_CheatEngineClientSdkFacingLibrary)' != 'true' and '%(_CheatEngineClientPinnedSdkReference.VersionOverride)' != '') or ('$(ManagePackageVersionsCentrally)' == 'true' and '%(_CheatEngineClientPinnedSdkReference.Version)' != '') or ('$(ManagePackageVersionsCentrally)' != 'true' and '%(_CheatEngineClientPinnedSdkReference.Version)' != '$(_CheatEngineClientExpectedSdkVersion)')"/> + + + + + + + + + <_CheatEngineClientRoslynCSharpPin>@(PackageVersion->WithMetadataValue('Identity', 'Microsoft.CodeAnalysis.CSharp')->'%(Version)') + <_CheatEngineClientRoslynAnalyzersPin>@(PackageVersion->WithMetadataValue('Identity', 'Microsoft.CodeAnalysis.Analyzers')->'%(Version)') + + + + + + + + false + + + + <_CheatEngineClientEvaluatedVersion>$(Version) + <_CheatEngineClientEvaluatedPackageVersion>$(PackageVersion) + + + + + + $(MinVerMajor).$(MinVerMinor).0.0 + + + + + + + <_CheatEngineClientMinVerPackageVersion>$(MinVerVersion.Split('+')[0]) + + + + + + + + + + <_CheatEngineClientOtherVersionReference Remove="@(_CheatEngineClientOtherVersionReference)"/> + <_CheatEngineClientOtherVersionReference Include="@(_ProjectReferencesWithVersions)" + Condition="'%(_ProjectReferencesWithVersions.ProjectVersion)' != '$(PackageVersion)'"/> + + + + <_ProjectReferencesWithVersions> + [$(PackageVersion)] + + + + + + + + $(PackageVersion) + + + + + + + <_CheatEngineClientSbomComponentPath>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', 'sbom-components')) + $(_CheatEngineClientSbomComponentPath) + + + + + + + + + + + + + + + + + + + + + + <_CheatEngineClientNoWarnCodes>;$([System.Text.RegularExpressions.Regex]::Replace('$(NoWarn)', '[\s,;]+', ';').ToUpperInvariant()); + <_CheatEngineClientWarningsNotAsErrorsCodes>;$([System.Text.RegularExpressions.Regex]::Replace('$(WarningsNotAsErrors)', '[\s,;]+', ';').ToUpperInvariant()); + <_CheatEngineClientDowngradesBlockingAudit>false + <_CheatEngineClientDowngradesBlockingAudit Condition="$(_CheatEngineClientNoWarnCodes.Contains(';NU1903;')) or $(_CheatEngineClientNoWarnCodes.Contains(';NU1904;')) or $(_CheatEngineClientWarningsNotAsErrorsCodes.Contains(';NU1903;')) or $(_CheatEngineClientWarningsNotAsErrorsCodes.Contains(';NU1904;'))">true + + <_CheatEngineClientIsCoexistenceFixture>$(_CheatEngineClientFolder.StartsWith('tests/CheatEngine.Client.LivePlugin.Coexistence/', System.StringComparison.OrdinalIgnoreCase)) + + <_CheatEngineClientLockFileOverride>false + <_CheatEngineClientLockFileOverride Condition="'$(_CheatEngineClientIsCoexistenceFixture)' == 'true' and '$(CoexistenceSdkPackageVersion)' != '' and '$(CoexistenceSdkPackageVersion)' != '$(CheatEngineSdkVersion)'">true + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index cd094fa..b2ecf69 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,13 @@ + + + true - + @@ -21,12 +24,23 @@ + + + + + + - + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dcac7ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AriusII, ShadowNineX and CheatEngine.Client contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4596fd3..7291ab8 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ **High-level, lifecycle-safe C# APIs for modern Cheat Engine plugins.** [![CI](https://img.shields.io/github/actions/workflow/status/CheatEngineNet/CheatEngine.Client/main-ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&labelColor=24292f)](https://github.com/CheatEngineNet/CheatEngine.Client/actions/workflows/main-ci.yml) +[![OpenSSF Scorecard](https://img.shields.io/ossf-scorecard/github.com/CheatEngineNet/CheatEngine.Client?style=flat-square&label=OpenSSF%20Scorecard&labelColor=24292f)](https://scorecard.dev/viewer/?uri=github.com/CheatEngineNet/CheatEngine.Client) [![NuGet](https://img.shields.io/nuget/vpre/CheatEngine.Client?style=flat-square&logo=nuget&logoColor=white&labelColor=24292f&color=004880)](https://www.nuget.org/packages/CheatEngine.Client) [![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4?style=flat-square&logo=dotnet&logoColor=white&labelColor=24292f)](https://dotnet.microsoft.com/download/dotnet/10.0) [![Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078D4?style=flat-square&labelColor=24292f)](#requirements) -[Quick start](#quick-start) · [Lifecycle](#the-plugin-lifecycle) · [Packages](#packages-and-direct-sdk-reference) · [Capabilities](#v010-capability-status) · [Contributing](#build-and-validation) +[Quick start](#quick-start) · [Lifecycle](#the-plugin-lifecycle) · [Packages](#packages-and-direct-sdk-reference) · [Capabilities](#capability-status) · [Contributing](CONTRIBUTING.md) @@ -53,16 +54,31 @@ surface reviewable: high-level APIs remain fluent for consumers while the Core r ## Requirements -| Requirement | Baseline | -|-----------------------------|------------------------------------------------------------------------------------| -| .NET SDK | 10.0.401 or later | -| Target framework / language | `net10.0` / C# 14 | -| Cheat Engine host | 7.7, Windows x64 | -| Plugin form | Framework-dependent managed plugin output folder | -| SDK package | `CheatEngine.SDK` 1.x; the Client publishes a compatible range of `[1.0.0, 2.0.0)` | +A plugin project that consumes the packages needs: + +| Plugin project requirement | Value | +|---|---| +| Target framework | `net10.0` | +| Language | C# 14 (`14.0`) | +| .NET SDK | 10.0.401 or later: the Lua generator packed in Hosting is compiled against Roslyn 5.9.0; an older compiler does not run it and reports only warning CS9057 | +| Platform | Windows x64; `PlatformTarget` is `x64` or `AnyCPU` | +| Cheat Engine host | 7.7.0.10621 x64 (`cheatengine-x86_64.exe`), loading the plugin through its managed .NET host | +| Plugin form | Framework-dependent managed plugin output folder, never a Native AOT DLL | +| `CheatEngine.SDK` | A direct `PackageReference` in `[2.0.0, 3.0.0)`: a 3.x SDK fails the build with `CECLIENT017`, a version below 2.0.0 fails the restore with `NU1605` | + +Building this repository needs: + +| Repository build requirement | Value | +|---|---| +| Operating system | Windows x64 | +| .NET SDK | 10.0.401 exactly (`global.json` `rollForward: disable`); install it with `winget install Microsoft.DotNet.SDK.10 --version 10.0.401` | +| `CheatEngine.SDK` | 2.0.0, pinned in `eng/CheatEngineSdk.props` and in every committed lock file | +| Tools | PowerShell 7.4 or later and Git, to run the CI checks locally ([CONTRIBUTING](CONTRIBUTING.md#prerequisites)) | +| Cheat Engine | Only for the opt-in live qualification tests, never for an ordinary build or test run | Cheat Engine remains the compatibility authority. The Client is not an IPC client, a remote-process service, or a -standalone executable; v0.1 runs only inside an enabled Cheat Engine plugin. +standalone executable; it runs only in process inside an enabled Cheat Engine plugin. It is neither Cheat Engine's +`luaclient` library nor an RPC client of `ceserver`. ## Quick start @@ -82,22 +98,31 @@ The generated project intentionally retains these direct dependencies: net10.0 14.0 + enable + enable x64 true true - - + + ``` +Replace `X.Y.Z` with the CheatEngine.Client version you install; the `ceplugin` template writes it for you. Keep +`CheatEngine.SDK` on 2.x: this Client release is built and tested against CheatEngine.SDK 2.0.0 and declares +`[2.0.0, 3.0.0)`. A 3.x SDK fails the build with `CECLIENT017`, and a version below 2.0.0 fails the restore with +`NU1605`. Do not upgrade to 3.x until a Client release says so. + `CheatEngine.SDK` must be referenced **directly by the plugin project**. Its build assets generate the Cheat Engine entry point and provide the native Lua bridge; NuGet transitivity is not sufficient at that host boundary. Setting -`CheatEngineClientPluginProject` opts the project into the Hosting package's `CECLIENT001` guard, which fails the -build if the direct SDK reference is removed. +`CheatEngineClientPluginProject` opts the project into the Hosting package's plugin profile checks, which fail the +build if the direct SDK reference is removed (`CECLIENT001`); the +[Hosting README](libs/CheatEngine.Client.Hosting/README.md#build-diagnostics) lists every `CECLIENT` build +diagnostic. Start with the template rather than copying this fragment into an existing plugin: it also demonstrates module registration, generated Lua exports, validated options, bounded AOB and typed-memory access, and an Address List @@ -106,12 +131,21 @@ snapshot. See the [template guide](templates/CheatEngine.Client.Templates/README ### Minimal plugin shape -The SDK still owns the plugin annotation. The Client base owns the activation-scoped composition: +The SDK still owns the plugin annotation. The Client base owns the activation-scoped composition, and an activation +module receives the scoped client in `OnEnabled` and `OnDisabling`. Fluent calls remain bounded and handle-free: ```csharp +using CheatEngine.Client; using CheatEngine.Client.Hosting; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; using CheatEngine.SDK.Annotations.Plugin; +using CheatEngine.SDK.Engine.Values; + using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; namespace MyPlugin; @@ -121,28 +155,47 @@ public sealed class Plugin : CheatEngineClientPlugin protected override void Configure(CheatEnginePluginBuilder builder) { builder.Configuration - .SetBasePath(AppContext.BaseDirectory) + .SetBasePath(builder.PluginDirectory) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); - builder.Client.AddModule(); + builder.Client.AddModule(); } } -``` -An activation module receives the scoped client in `OnEnabled` and `OnDisabling`. Fluent calls remain bounded and -handle-free: +public sealed class ScoreModule(ILogger logger) : ICheatEngineClientModule +{ + public void OnEnabled(ICheatEngineClient client) + { + // A Try form returns an expected failure, such as no selected process, instead of throwing it. + if (!client.Patterns.Aob("48 8B ?? ?? ?? 89") + .InModule("game.exe") + .Executable() + .RequireSingle() + .TryExecute(out Address address, out CheatEngineFailure failure)) + { + logger.LogDebug("Score probe skipped: {Failure}", failure); + return; + } + + client.Memory.At(address + 0x14).Write(999); + } -```csharp -Address address = client.Patterns - .Aob("48 8B ?? ?? ?? 89") - .InModule("game.exe") - .ReadableExecutable() - .RequireSingle() - .Execute(); - -client.Memory.At(address + 0x14).Write(999); + public void OnDisabling(ICheatEngineClient client) + { + } +} ``` +`InModule(...)` and `InRange(...)` scope the scan with one rule on every route (a match lies entirely inside the module +and starts inside the range): on a qualified local target Cheat Engine scans only the module intersected with the range +(an exhaustive MemScan that blocks its main thread and cannot be interrupted once started); on a CEServer or +file-as-process target it runs one global `AOBScan` and Core applies the same rule while copying. `Take(n)`, +`FirstOrNone()` and `RequireSingle()` bound only how many addresses Core copies; they never stop Cheat Engine early, and +`FirstOrNone()` follows Cheat Engine's unspecified result-list order. `null` and `NotFound` mean that the scan succeeded +without a match inside the request: a factual zero of the bounded route, or a global result list without any address +inside the module or range. A global scan for which Cheat Engine returns no result list is reported as +`IndeterminateHostResult` (that route cannot tell zero matches from a host failure), never as `null` or `NotFound`. + Use the `Try...` terminal operations when absence of a process, scan result, or runtime capability is an expected condition. Do not make a worker wait for the Cheat Engine thread if that worker can call back into the Client. @@ -174,10 +227,27 @@ All Client operations are synchronous. For stateful Client operations, request v admission, then caller-cancellation observation, and only then policy checks or Cheat Engine work. A stale activation therefore throws `CheatEngineActivationExpiredException` even when the requested capability is currently gated. A cancellation token can prevent dispatch or stop Client-managed work between steps, but it does not claim to interrupt -a Lua primitive that has already started. `ILocalProcessDiagnostics` is the explicit exception: it is an offline BCL -catalog service, never a proof of Cheat Engine target identity, and its copied snapshots remain usable after disable. +a Lua primitive that has already started. `IProcessClient.TryGetLocalProcesses` is the explicit exception: it is an +offline BCL catalog read, never a proof of Cheat Engine target identity, and its copied snapshots remain usable after +disable. Services that touch Cheat Engine must preserve this activation-lifecycle contract. +### Failures, exceptions and cancellation + +`Try...` methods return a `CheatEngineFailure` for the refusals of a well-formed request, cancellation before dispatch, +and Cheat Engine results that are false, absent, indeterminate or malformed; no `Try...` method throws a +CheatEngine.SDK exception. They still throw an argument exception for a null argument, a `default` +request, an undefined enum value or an out-of-range number (checked first, before the activation and before any Cheat +Engine call), `CheatEngineActivationExpiredException` and `CheatEngineInvalidStateException`, and they rethrow +exceptions from your own callbacks, codecs and Lua operations unchanged. +The throwing form of the same operation throws the same failure through `CheatEngineFailure.Throw(cancellationToken)`: +a cancellation surfaces as `CheatEngineOperationCanceledException`, an `OperationCanceledException`, and every other +failure as a `CheatEngineClientException`. Releasing a lease never throws: `ICheatEngineLease.Release()` returns a +`LeaseReleaseOutcome`, and `Dispose()` discards it. +`CheatEngineFailure.HostEffect` states how far the Cheat Engine primitive got: `NotStarted`, `Started`, `Completed`, +`NotApplied`, `CleanupUnconfirmed` or `Unknown`. The per-family table is in the +[Abstractions README](libs/CheatEngine.Client.Abstractions/README.md#failure-exception-and-cancellation-contract). + ## Packages and direct SDK reference The recommended package is `CheatEngine.Client`. The delivery graph stays deliberately one-way: @@ -197,8 +267,8 @@ public contracts ───────────────────── | Package | Purpose | Consume directly when | |-------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|--------------------------------------------------------| | [`CheatEngine.Client`](src/CheatEngine.Client/README.md) | Umbrella package for the high-level fluent and hosting experience | Building a normal plugin | -| [`CheatEngine.Client.Hosting`](libs/CheatEngine.Client.Hosting/README.md) | `CheatEngineClientPlugin` and one-provider-per-activation host | Integrating the host into an existing composition root | -| [`CheatEngine.Client.Extensions.DependencyInjection`](libs/CheatEngine.Client.Extensions.DependencyInjection/README.md) | Explicit DI registrations, modules, memory codecs, and options | Composing the Client without the plugin base | +| [`CheatEngine.Client.Hosting`](libs/CheatEngine.Client.Hosting/README.md) | `CheatEngineClientPlugin` and one-provider-per-activation host | Hosting a plugin (the supported composition root) | +| [`CheatEngine.Client.Extensions.DependencyInjection`](libs/CheatEngine.Client.Extensions.DependencyInjection/README.md) | Hosting's composition layer: DI registrations and options | Only through Hosting in 1.0 (standalone unsupported) | | [`CheatEngine.Client.Fluent`](libs/CheatEngine.Client.Fluent/README.md) | Immutable fluent memory and AOB builders | Depending only on fluent request construction | | [`CheatEngine.Client.Abstractions`](libs/CheatEngine.Client.Abstractions/README.md) | Contracts, requests, failures, and value vocabulary | Referencing contracts without an implementation | | [`CheatEngine.Client.Core`](libs/CheatEngine.Client.Core/README.md) | SDK-facing implementation | Normally composed through DI, not called directly | @@ -207,35 +277,93 @@ public contracts ───────────────────── Package and assembly names describe delivery, not user code. Consumer-facing APIs use functional namespaces such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, `.Processes`, `.Runtime`, and `.Hosting`. -## v0.1.0 capability status +## Capability status The Client reports runtime capability rather than assuming a particular Cheat Engine global or ownership contract. The -following table is a delivery statement, not a substitute for a live host check. - -| Area | v0.1.0 status | Boundary | -|-----------------------------------------------------|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Lifecycle, dispatch, DI, modules, options | Available | Per-enable provider and scope; modules stop in reverse order | -| Runtime facts and selected process | Available | Snapshot and attachment state are re-read through the active host | -| Typed memory and finite pointer chains | Available | Built-in primitives plus explicitly registered deterministic codecs; strings and byte ranges are bounded | -| Modules, regions, symbols, and custom-symbol leases | Available | Results are copied; leases are activation-scoped | -| AOB scanning | Available | Patterns are normalized; terminals are `FirstOrNone`, `RequireSingle`, or bounded `Take` | -| Address List and memory records | Available | Snapshots and hierarchy materialization are bounded; table file access requires an allowed root | -| Typed protected Lua and explicit Lua modules | Available | No Lua state crosses the public Client contract | -| Value scanning | **Capability-gated** | The public state machine exists, but Client session creation stays unavailable until the internal `MemScan`/`FoundList` ownership path passes its Cheat Engine 7.7 x64 live gate | -| Arbitrary Lua source | Policy-gated and off by default | Requires explicit unsafe opt-in; raw Lua state remains hidden | - -Target allocations; assembly and Auto Assembler; remote execution and DLL injection; debugger and breakpoints; -hotkeys and timers; speed; hashing; and DBVM now have public Client API contracts. Each remains -`Unknown` or `Unavailable` until its primitive, ownership, and lifecycle behavior passes the corresponding Cheat -Engine 7.7 x64 live gate. IPC, remote clients, UI/forms, structures, Mono/IL2CPP, and advanced ABI hooks remain -outside v0.1 and have no placeholder public API. The capability table above is the current public-surface contract. +following table is a delivery statement, not a substitute for a live host check. **Available** means a stable 1.x API +with an operational implementation; **Experimental** APIs are operational too, but carry an +`[Experimental("CECLIENT500x")]` diagnostic and can change in a minor release until their live scenarios pass. At run +time no capability reports the state `Available` before Client qualification receipts exist for the scenarios of its +row. + + +| Capability id | Implementation | 1.0 status | Boundary | Qualification | +|---|---|---|---|---| +| `Client.ProcessSelection` | Operational adapter | Available | Snapshot and attachment state are re-read through the active host | Unknown until Client receipts for Q30.a, Q31 and Q32 exist | +| `Client.TypedMemory` | Operational adapter | Available | Built-in primitives (8- to 64-bit integers, float, double, Address) plus codecs passed with each request; strings and byte ranges are bounded | Unknown until Client receipts for Q20, Q21 and Q33 exist | +| `Client.PatternScanning` | Operational adapter | Available | Patterns are normalized; terminals are `FirstOrNone`, `RequireSingle`, or materialization-bounded `Take`; module and range scans are bounded on a qualified local target | Unknown until Client receipts for Q27, Q28 and Q29 exist | +| `Client.Inspection` | Operational adapter | Available | Modules, regions and symbols are copied; custom-symbol leases are activation-scoped | Unknown until Client receipts for Q16.b and Q28 exist | +| `Client.Tables` | Operational adapter | Available | Snapshots and hierarchy materialization are bounded; table file access requires an allowed root | Unknown until Client receipts for Q34 exist | +| `Client.ProtectedLua` | Operational adapter | Available | Typed Lua operations and explicit Lua modules; no Lua state crosses the public Client contract | Unknown until Client receipts for Q05, Q16 and Q19 exist | +| `Client.UnsafeLuaExecution` | Operational, policy opt-in | Available with `EnableUnsafeLuaExecution()`, off by default | Arbitrary trusted Lua source; raw Lua state remains hidden | Stays `Unknown`: no scenario covers arbitrary Lua | +| `Client.ValueScanning` | Operational adapter, experimental (CECLIENT5001) | Experimental | Sessions over CheatEngine.SDK's owned `MemScan`/`FoundList`: first and next scans, bounded pages of copied results | Unknown until Client receipts for Q25 and Q26 exist | +| `Client.Allocations` | Operational adapter, experimental (CECLIENT5002) | Experimental | Leases over CheatEngine.SDK's `AllocatedRegion`, freed only in the process that made them; executable memory needs no opt-in | Unknown until Client receipts for Q30.a exist | +| `Client.Assembly` | Operational adapter, experimental (CECLIENT5003) | Experimental | One profile-checked instruction per call, bytes read from target memory, nothing written to the target | Unknown until Client receipts for Q32 exist | +| `Client.AutoAssemblerPatches` | Operational, policy opt-in, experimental (CECLIENT5004) | Experimental, with `EnableAutoAssemblerPatches()` | A lease owns the disable information Cheat Engine returned | Unknown until Client receipts for Q35 and Q44 exist | + + +The activation lifecycle, main-thread dispatch, runtime facts, dependency injection, options and modules are always +available. IPC, remote clients, UI/forms, structures, Mono/IL2CPP, and advanced ABI hooks remain outside 1.0 and have no +placeholder public API. The capability table above is the current public-surface contract; the +[Abstractions README](libs/CheatEngine.Client.Abstractions/README.md#capability-boundary) states the runtime evidence of +each capability. + +### Not offered in 1.0 + +These Cheat Engine features have no public Client contract, not even a gated placeholder: timers and hotkeys; the +debugger and breakpoints; the speed hack; target-memory and file hashing; DBVM; remote execution and DLL injection; +pausing, resuming or creating a process, and attaching to the foreground process; assembly comments; and detaching from +a process. No CheatEngine.SDK primitive backs these yet; they may arrive in a 1.x minor release once the SDK provides +an owner. + +CheatEngine.SDK 2.0.0 also resolves addresses in Cheat Engine's own process (`EngineInspection.ResolveHostAddress`) +and registers symbol lists (`SymbolLists`). Neither is a 1.0 goal of the Client: `IInspectionClient` resolves in the +target process only and registers one symbol per lease. + +## Versioning and compatibility + +CheatEngine.Client follows [Semantic Versioning 2.0.0](https://semver.org/) from 1.0.0. Every Client package is +released with the same version; use one version for all of them. + +The seven packages ship in lockstep. Each Client package depends on the Client packages it builds on at exactly its own +version (`[X.Y.Z]` in its nuspec, not the `X.Y.Z` minimum NuGet writes by default), because +`CheatEngine.Client.Extensions.DependencyInjection` and `CheatEngine.Client.Hosting` use internal types of +`CheatEngine.Client.Core` and of `CheatEngine.Client.Extensions.DependencyInjection`, which no public API baseline +protects. Reference `CheatEngine.Client` and let it bring the others; a Client package you reference directly takes the +same version. `CheatEngine.Client.Core` is not a standalone package: it has no public API and is published only as a +dependency of `CheatEngine.Client.Extensions.DependencyInjection`. + +- **Patch releases (1.0.x)** fix behavior and documentation without changing the public API. +- **Minor releases (1.x)** add API without breaking code compiled against an earlier 1.x: + - new types, members, overloads and namespaces; + - new members on a **call-only** interface. The documentation of every public interface says whether it is + *Call-only* (the Client implements it and applications call it, for example `ICheatEngineClient`, `IMemoryClient` + or `ICheatEngineLease`) or *Implementable* (applications implement it and the Client calls it). Implement a + call-only interface only in a test double, and expect to update the double in a minor release; + - new enum values. Public enums are `int` enums whose explicit values never change meaning. An outcome enum (a name + ending in `Kind`, `Status`, `State`, `Effect` or `Scope`) has `Unknown = 0`: handle a value you do not recognize + like `Unknown`. An option enum has a valid default at 0 and never an outcome suffix. +- **Frozen for all of 1.x:** the *Implementable* interfaces (`ILuaModule`, `ILuaOperation`, + `ILuaResultMapper`, `IMemoryCodec` and `ICheatEngineClientModule`) never gain, lose or change a + member. +- **Experimental APIs**, marked `[Experimental("CECLIENT500x")]`, can change or be removed in a minor release until + their live qualification passes; using one is an explicit opt-in to that diagnostic. +- **Charter:** the public API charter of the `CheatEngine.Client.Abstractions` README fixes the Try and throwing forms, + the names, the exception policy (no Client exception has a public constructor; `CheatEngineFailure.Throw` and + `ToException` create them) and the CheatEngine.SDK value types a public signature may use; 1.x only adds to it. +- **Not contractual:** the text of `CheatEngineFailure.Message`, of `CheatEngineFailure.Operation` and of exception + messages. Classify a failure by `CheatEngineFailure.Kind` and `HostEffect`, never by text. +- **CheatEngine.SDK:** Client 1.x depends on CheatEngine.SDK `[2.0.0, 3.0.0)`. Its descriptive value types (`Address`, + `PointerSize`, `ModuleInfo` and the others the charter lists) are part of the Client's public signatures, so a new + CheatEngine.SDK major version means a new Client major version, never a Client minor release. +- Removing or changing a stable public member, or changing the meaning of a value, happens only in a new major version. ## AOT, trimming, and deployment Shipping Client projects target `net10.0`, enable nullable analysis, warnings as errors, trim/AOT compatibility -analysis, reference-AOT verification, deterministic builds, XML documentation, Source Link, symbol packages, and -package/API validation. `CheatEngine.Client.AotProbe` publishes the complete Client graph as Native AOT for `win-x64` -to validate those library constraints. +analysis, trim and AOT compatibility verification of every referenced assembly, deterministic builds, XML documentation, +Source Link, symbol packages, and package/API validation. `CheatEngine.Client.AotProbe` publishes the complete Client +graph as Native AOT for `win-x64` to validate those library constraints. That is **not** a claim that Cheat Engine can load a Native AOT plugin DLL. The supported deployment remains the framework-dependent managed plugin output folder. Deploy it as one unit: your plugin assembly, its `.deps.json` and @@ -247,26 +375,17 @@ files, package validation, and the probe described above. ## Build and validation -The repository pins the .NET SDK in [global.json](global.json), uses Central Package Management, and commits NuGet -lock files. Run the normal Windows validation sequence from the repository root: +The repository pins the .NET SDK in [global.json](global.json) (10.0.401 exactly), uses Central Package Management, and +commits NuGet lock files. [CONTRIBUTING](CONTRIBUTING.md#build-and-test) gives the exact Windows validation sequence, +from the locked restore to the package consumption tests and the Native AOT probe, and describes each job of the +required `CI / Gate` check ([Continuous integration](CONTRIBUTING.md#continuous-integration)). -```powershell -dotnet restore CheatEngine.Client.slnx --locked-mode -dotnet build CheatEngine.Client.slnx --configuration Release --no-restore -dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore -dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore --output ./artifacts/packages -$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path ./artifacts/packages).Path -dotnet test --project ./tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on -dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj --configuration Release --runtime win-x64 --no-restore --output ./artifacts/aot-probe -./artifacts/aot-probe/CheatEngine.Client.AotProbe.exe -``` +The Cheat Engine 7.7 x64 live suite is opt-in and intentionally excluded from ordinary CI; no CI result should be read +as proof that an untested live-host feature is available. Success, failure, cleanup, disable, re-enable, and +target-change evidence for every advanced capability still requires the opt-in Cheat Engine 7.7 x64 qualification run. -The [Windows CI workflow](.github/workflows/ci.yml) runs the locked restore, Release build, Microsoft Testing Platform -tests, package API validation, one immutable package artifact, the C# package/template consumer smoke test, and Native -AOT graph probe. The -Cheat Engine 7.7 x64 live suite is opt-in and intentionally excluded from ordinary CI; no CI result should be read as -proof that an untested live-host feature is available. Success, failure, cleanup, disable, re-enable, and target-change -evidence for every advanced capability still requires the opt-in Cheat Engine 7.7 x64 qualification run. +See also [CONTRIBUTING](CONTRIBUTING.md), [RELEASING](RELEASING.md), the [CHANGELOG](CHANGELOG.md) and the +[LICENSE](LICENSE). ## Security and scope @@ -275,8 +394,11 @@ transport, or a mechanism to bypass Cheat Engine or host protections. Table loading can execute Lua in the host. Keep `AllowedTableRoots` empty unless the plugin has an explicit, trusted import/export location; an empty set disables table file access. Arbitrary Lua source is separately opt-in and -should remain disabled unless the plugin has a deliberate trust boundary. Avoid logging target-memory contents or Lua -source by default. +should remain disabled unless the plugin has a deliberate trust boundary. Auto Assembler patches are another explicit, +experimental opt-in (`EnableAutoAssemblerPatches()`): a script can allocate memory, inject code and run Lua, so apply +only scripts the plugin owns. Avoid logging target-memory contents or Lua source by default. + +Report vulnerabilities privately: see [SECURITY.md](SECURITY.md). For the SDK's bootstrap, generated Lua bindings, native bridge, and host ABI details, start with the [CheatEngine.SDK README](https://github.com/CheatEngineNet/CheatEngine.SDK#readme). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..ede80dd --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,353 @@ +# Releasing CheatEngine.Client + +The seven Client packages are released together, with one version, from one tag: + +| Package | Content | +|-----------------------------------------------------|-------------------------------------------------------------| +| `CheatEngine.Client` | The umbrella package a plugin references | +| `CheatEngine.Client.Abstractions` | Contracts, requests, failures and value vocabulary | +| `CheatEngine.Client.Core` | The SDK-facing implementation | +| `CheatEngine.Client.Extensions.DependencyInjection` | Hosting's composition layer: DI registrations and options | +| `CheatEngine.Client.Fluent` | Immutable fluent builders | +| `CheatEngine.Client.Hosting` | The plugin host, the Lua generator and the consumer targets | +| `CheatEngine.Client.Templates` | The `dotnet new ceplugin` template | + +1.0.0 is the first release: no Client version was tagged or published before it. It consumes `CheatEngine.SDK` 2.0.0 +and declares `[2.0.0, 3.0.0)`. The release workflow and this procedure come from the September 2026 audit remediation, +whose pull request (#59) is also the 1.0.0 release pull request. + +## One-time setup + +Every step of this section is a maintainer action in the nuget.org and GitHub settings; nothing in the repository can +perform it, and the first release needs all of it. + +### Trusted publishing + +nuget.org accepts the packages only from the release workflow, through +[trusted publishing](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing): the workflow exchanges a GitHub +OIDC token for a short-lived API key, and no long-lived NuGet API key is stored anywhere. Create the policy at + with these exact values: + +| Field | Value | +|------------------|----------------------------------------| +| Policy owner | `CheatEngine` (organization) | +| Repository owner | `CheatEngineNet` | +| Repository | `CheatEngine.Client` | +| Workflow file | `release.yml` | +| Environment | `nuget` | +| Scope | Push new packages and package versions | +| Package glob | `CheatEngine.Client*` | + +The policy belongs to the `CheatEngine` organization of nuget.org, not to a person, so it covers the packages that +organization owns. Its creator must stay an active member of the organization: nuget.org deactivates a policy whose +creator leaves it. The glob covers the seven package ids, including `CheatEngine.Client.Templates`. None of them +exists on nuget.org before 1.0.0, so the scope must allow new packages. + +### The `nuget` environment + +The GitHub environment `nuget` gates the `publish` job: + +- **Deployment branches and tags:** only tags matching `v*.*.*`. +- **Required reviewers:** the maintainers who approve a publication; `publish` waits until one of them approves the + deployment. While a single maintainer publishes, leave **Prevent self-review** off: the maintainer who pushed the + tag must be able to approve its deployment. +- **Environment secret `NUGET_USER`:** `AriusII`, the nuget.org profile name of the organization member who created + the policy (the `user` input of `NuGet/login`), not an e-mail address and not the organization name. + +A key obtained through trusted publishing is valid for one hour, and each OIDC token yields one key, so the workflow +logs in once, right before it pushes the seven packages. The login and the pushes stay in the `publish` job of +`release.yml`, because the policy names that file and that environment. + +### Tag protection + +Add a repository ruleset that targets the tags matching `v*`, with **Restrict updates**, **Restrict deletions** and +**Block force pushes**, so that a pushed release tag keeps its commit. Without a bypass actor, nobody can move, delete +or re-push a release tag, not even to retry a publication that was rejected before any package reached nuget.org +([Re-run a release](#re-run-a-release) describes that case): either add the repository administrators as bypass actors +for it, or release the fix as a new version. + +### Immutable releases + +Once `release.yml` is on `main`, enable immutable releases in the repository settings. A published release then keeps +its tag and its assets forever, which is why the workflow publishes a release only after it carries every asset. The +workflow runs `gh release verify` and `gh release verify-asset` when the release is immutable, and emits a warning +otherwise. + +## Prepare a release + +1. Move the entries of `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md) to a `## [X.Y.Z] - YYYY-MM-DD` section. Its + body becomes the GitHub release notes; a stable tag fails without it, a prerelease tag falls back to `[Unreleased]`. + `## [Unreleased]` keeps its four category headings, empty. For 1.0.0, the section summarizes the initial release by + feature and contract instead of listing every change of the remediation. +2. Ship the public API and the analyzer rules of the release, by hand, in every project that changed: + - move every entry of each project's `PublicAPI.Unshipped.txt` into its `PublicAPI.Shipped.txt` (keep `*REMOVED*` + lines), leaving `#nullable enable` first and the file empty otherwise; + - move the rows of `AnalyzerReleases.Unshipped.md` into a new `## Release X.Y.Z` section of + `AnalyzerReleases.Shipped.md`. + + Experimental entries keep their `[CECLIENT500x]` prefix in `PublicAPI.Shipped.txt`. For 1.0.0, every Shipped file + starts empty and receives the whole 1.0.0 surface. Make both moves in one commit, the last API commit of the release + pull request: `PublicApiFileTests` reads the `## Release X.Y.Z` section as the promotion, keeps every Shipped file + empty and forbids `*REMOVED*` entries until the first one exists, and requires each one to name a dated + `CHANGELOG.md` release. + + Then confirm the move compiles clean (RS0016/RS0017/RS0025, RS2000–RS2008): + + ```powershell + dotnet build CheatEngine.Client.slnx -c Release + ``` +3. Set `MinVerMinimumMajorMinor` in [Directory.Build.props](Directory.Build.props) to the line being released. The + exact version comes from the `vX.Y.Z` tag; the property is only the floor for untagged commits. When the value + changes, the evaluation-time `VersionPrefix` follows it and NuGet records it in the project-reference entries of the + lock files (the three coexistence fixture locks included), so regenerate them in the same pull request with + `dotnet restore --force-evaluate` for each affected project. +4. Check the qualification gate below. +5. Rehearse the pack locally with the version the tag will produce, and inspect the seven packages. The build takes the + override too, so that the assemblies carry the version of their packages; these are the first commands of the + qualification run below, and they produce the same rehearsal build: + + ```powershell + dotnet restore CheatEngine.Client.slnx --locked-mode + dotnet build CheatEngine.Client.slnx -c Release --no-restore -p:MinVerVersionOverride=X.Y.Z + Remove-Item artifacts/rehearsal -Recurse -Force -ErrorAction SilentlyContinue + dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/rehearsal -p:MinVerVersionOverride=X.Y.Z + ``` + + Never leave `MinVerVersionOverride` set as an environment variable: MinVer reads it from the environment too. +6. Merge the release pull request once `CI / Gate` passes, as the + [merge policy](CONTRIBUTING.md#merge-policy) says: a squash merge, unless the pull request contains a commit that + [`.git-blame-ignore-revs`](.git-blame-ignore-revs) lists, which takes a merge commit. The 1.0.0 release pull request + (#59) contains such commits: merge it with a merge commit, never squash or rebase it, and tag that merge commit. +7. Start a dry run of `release.yml` on the updated `main` (see the end of [Publish](#publish)) and wait until `verify`, + `ci` and `stage` pass before you push the tag. + +## Qualification gate + +A Client release is a claim about a tuple: the Client version, the exact `CheatEngine.SDK` package it consumes, that +package's native bridge, the Cheat Engine host profile and the load profile. For 1.0.0 the tuple is the seven 1.0.0 +packages, `CheatEngine.SDK` 2.0.0 with the content hash and bridge SHA-256 that the install guides state, and Cheat +Engine 7.7.0.10621 x64 (`cheatengine-x86_64.exe`) loading a managed plugin through hostfxr +(`ce-7.7.0.10621-x64-managed-hostfxr`). + +The gate goes through the live qualification runner of +[`tests/CheatEngine.Client.Tests`](tests/CheatEngine.Client.Tests/README.md#live-qualification). It builds the plugins +from the packed release candidates, loads them into a sandboxed copy of Cheat Engine that drives disposable gtutorial +targets, restores the user's Cheat Engine state, and writes redacted receipts and a run summary. That README gives the +prerequisites; the whole run, from the repository root with Cheat Engine, every gtutorial and DebugView closed, is: + +```powershell +dotnet restore CheatEngine.Client.slnx --locked-mode +dotnet build CheatEngine.Client.slnx -c Release --no-restore -p:MinVerVersionOverride=X.Y.Z +Remove-Item artifacts/rehearsal -Recurse -Force -ErrorAction SilentlyContinue +dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/rehearsal -p:MinVerVersionOverride=X.Y.Z +$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path artifacts/rehearsal).Path +$env:CHEATENGINE_CLIENT_LIVE_QUALIFICATION = 'I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET' +dotnet test --project tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj -c Release --no-build --filter-trait Category=LiveQualification --filter-not-trait Session=S0 +Remove-Item Env:CHEATENGINE_CLIENT_LIVE_QUALIFICATION +``` + +The command runs the sessions S1 to S6 and leaves out the S0 spike (`Session=S0`), whose receipts are never committed. +The operator stays at the workstation for the Settings > Plugins toggles the runner asks for. A first run finds the +defects; after their fixes, a second run on the committed, clean tree is the one recorded. Its redacted receipts, its +summary and any dated waiver are committed under `tests/CheatEngine.Client.Tests/LiveQualification/Evidence/`. The +summary binds them to the shipping sources through `QualifiedSourceDigest`, so a change to any digest input after that +run requires a new run. The receipts cover the rehearsal build, whose sources have the same digest as the release +commit; they do not carry the hashes of the packages the release workflow builds. Before tagging, the evidence must +show for the tuple: + +- the release gate: Q09 (two Client plugins in one Cheat Engine process), Q10 (next to a CheatEngine.SDK 1.x plugin; + a failure is published as an unsupported mix of SDK majors), Q40 (clean installation of the packages and the + template on the exact host profile, in addition to the package consumption tests that CI runs on every change), Q43 + (the aggregated cleanup when the operator disables a plugin with a faulty module), Q44 (the policy refusal of an API + that needs an opt-in), Q45 (sensitive probes change no target byte, process or module) and Q46 (log redaction), each + with its receipts or an explicit waiver, dated, for what cannot run on the host; +- a green consumer-contract run against the pinned SDK (Q48 at the managed-test level, which CI runs on every change); +- the scenarios of each capability (`ClientCapabilityCatalog`): the qualification gate of a capability requires + committed evidence that each of its scenarios succeeded without a waiver, and stays `Unknown` without it; an + experimental id is lifted only when every scenario of its capability succeeds without a waiver (`CECLIENT5001`: Q25 + and Q26; `CECLIENT5002`: Q30.a; `CECLIENT5003`: Q32; `CECLIENT5004`: Q35 and Q44); +- that audit finding F05 (Client and SDK diverge) stays open until `Client.ValueScanning` has the Q25 and Q26 + receipts: the Client composes the consumed `CheatEngine.SDK` value-scan sessions through an experimental adapter + (`CECLIENT5001`), and closing F05 takes those receipts. + +The recorded run's id is written here, and its scenario results go to the `CHANGELOG.md` section of the release, only +from that evidence. Until it is committed, no document claims a host qualification (`QualificationEvidenceTests`). A CI +result is never presented as a host result, and a scenario that was not run stays not run. + +## Publish + +The release commit must first land on the first-parent history of `main`; the workflow rejects a tag on any other +commit. From an up-to-date `main` checkout: + +```powershell +git tag -a vX.Y.Z -m "Release X.Y.Z" +git push origin vX.Y.Z +``` + +The tag starts [`release.yml`](.github/workflows/release.yml): + +```text +verify ─► ci ─► stage ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release +``` + +The workflow declares the seven package ids once, as `PACKAGE_IDS` in dependency order, and every job derives its list +of packages from it. + +1. `verify` checks the SemVer tag, that it points to the first-parent history of `main`, and that none of the seven ids + already has the version on nuget.org (a version can never be replaced). It extracts the release notes from + `CHANGELOG.md`. +2. `ci` runs the reusable `ci.yml` on the tag commit with the expected version: it builds and tests Debug and Release, + packs the tested Release build, fails unless every file is named `.X.Y.Z.nupkg`, runs the package consumption + tests on those exact files, and uploads them as the `nuget-packages` artifact. +3. `stage`, with a read-only token and no OIDC token, checks that `nuget-packages` holds exactly the seven packages + and their symbol packages, extracts the exact bytes of the SPDX 2.2 SBOM each package embeds (after checking that + it describes that package and version), writes `SHA256SUMS` over the packages, symbol packages and SBOMs, and + uploads them as the `release-staging` artifact. +4. `attest` checks every staged file against the staged `SHA256SUMS`, then signs one build provenance attestation over + the seven packages and the five symbol packages, and one SPDX SBOM attestation per package (predicate + `https://spdx.dev/Document/v2.2`). Before anything is drafted or pushed, it verifies each attestation against its + bundle and in the repository with the identity of [Verify a release](#verify-a-release), then writes the + `SHA256SUMS` of the release over every asset. +5. `draft-release` creates a draft release with every asset. Every draft already on the tag (an interrupted run or an + earlier build) is deleted first with `gh release delete`, which finds a draft by its tag and keeps the git tag, and + the draft is created again from this run's files, so the release always carries exactly the packages `publish` + pushes, never a patched-over asset set (that path breaks immutable releases). +6. `publish` waits for a required reviewer to approve the `nuget` deployment. Before it logs in, it checks every + `.nupkg` and `.snupkg` against `SHA256SUMS`: each one must have the listed SHA-256, and every package the file lists + must be there. It then logs in through trusted publishing and pushes the seven packages in dependency order + (Abstractions, Fluent, Core, Extensions.DependencyInjection, Hosting, CheatEngine.Client, Templates) without their + symbols, and only then the five symbol packages, all with `--skip-duplicate`. A failed symbol push never leaves a + package unpublished; [Re-run a release](#re-run-a-release) gives the recovery. +7. `verify-publication` resolves the package base address (`PackageBaseAddress/3.0.0`) from the nuget.org service + index, waits until nuget.org lists the seven versions, then checks each served file: the nuget.org repository + signature (`dotnet nuget verify --all`), a content hash equal to that of the attested package, and exactly the + attested entries, byte for byte, plus `.signature.p7s`. +8. `finalize-release` reads the draft with `gh release view` and publishes it with `gh release edit --draft=false`. It + then downloads the published assets, checks every one of them against `SHA256SUMS`, runs `gh release verify` and + `gh release verify-asset` when the release is immutable, verifies the provenance and SBOM attestations of the + downloaded packages with the same identity, and lists the asset hashes in the job summary. + +No job of the release path restores from or saves to a NuGet cache. Only `publish` has the `nuget` environment and +reads a secret; only `attest` and `publish` receive an OIDC token; the `contents: write` token of `draft-release` +and `finalize-release` reaches only their `gh` steps, never the restore and test steps. + +To rehearse the pipeline without publishing, start `Release` manually (**Actions → Release → Run workflow**), from a +branch or from a tag. Only a tag push of `CheatEngineNet/CheatEngine.Client` releases: for a `workflow_dispatch`, +even one started from a tag, `verify` writes no version, and `attest`, `draft-release` and `publish` (which share one +condition: a push, a tag, this repository and a verified version) are skipped, with every job after them. The dry run +executes `verify`, the full `ci` job and `stage`, which extracts the SBOMs and writes `SHA256SUMS` for the version +MinVer gave the packages, with a read-only token and no OIDC token: the `stage` job summary lists the hashes, and the +`release-staging` artifact holds the files. A dispatch requires the workflow on the default branch, so the first dry +run happens after the remediation branch is merged. + +## What a release contains + +| Asset | Content | +|-----------------------------------------------------|-----------------------------------------------------------------------------| +| `.X.Y.Z.nupkg` (7) | The attested packages; nuget.org serves the same content, repository-signed | +| `.X.Y.Z.snupkg` (5) | Portable PDBs with Source Link, for the five packages with build output | +| `.X.Y.Z.spdx.json` (7) | The SPDX 2.2 SBOM embedded in each package, extracted byte for byte | +| `CheatEngine.Client.X.Y.Z.provenance.sigstore.json` | The build provenance bundle of the 7 packages and the 5 symbol packages | +| `.X.Y.Z.sbom.sigstore.json` (7) | The SBOM attestation bundle of each package | +| `SHA256SUMS` | The SHA-256 of every other asset, in `sha256sum` format | + +The attestations are also stored in the repository, so `gh attestation verify` needs no bundle. Once immutable releases +are enabled, GitHub adds a release attestation over the tag, its commit and every asset, which `gh release verify` and +`gh release verify-asset` check. + +Each SBOM describes its package (name and version equal to the nuspec) and hashes every file in it. It also lists the +package's resolved NuGet graph from the build's `project.assets.json`: runtime dependencies such as `CheatEngine.SDK` +and `Microsoft.Extensions.*`, and build-only tools such as MinVer, the analyzers, `Microsoft.Sbom.Targets` and the +template tasks, which ship nothing into the package. Because the SBOM carries a unique namespace and a creation time, +a `.nupkg` is not byte-reproducible; reproducibility is promised for the assemblies it contains. + +## Verify a release + +```powershell +gh release download vX.Y.Z --repo CheatEngineNet/CheatEngine.Client --dir release +cd release +Get-Content SHA256SUMS | ForEach-Object { $hash, $name = $_ -split ' '; if ((Get-FileHash $name -Algorithm SHA256).Hash -ne $hash) { throw "$name" } } +$identity = @( + '--repo', 'CheatEngineNet/CheatEngine.Client', + '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', + '--source-ref', 'refs/tags/vX.Y.Z', + '--deny-self-hosted-runners' +) +foreach ($package in Get-ChildItem *.nupkg, *.snupkg) { + gh attestation verify $package @identity --predicate-type https://slsa.dev/provenance/v1 +} +foreach ($package in Get-ChildItem *.nupkg) { + gh attestation verify $package @identity --predicate-type https://spdx.dev/Document/v2.2 +} + +# Once immutable releases are enabled: +gh release verify vX.Y.Z --repo CheatEngineNet/CheatEngine.Client +foreach ($asset in Get-ChildItem *.nupkg, *.snupkg, SHA256SUMS) { + gh release verify-asset vX.Y.Z $asset --repo CheatEngineNet/CheatEngine.Client +} +``` + +The identity flags matter: `--signer-workflow` and `--source-ref` accept only an attestation that `release.yml` signed +for that exact tag, and `--deny-self-hosted-runners` only one made on a GitHub-hosted runner, so an attestation made by +another workflow, branch or runner of the repository does not verify. The first loop checks the build provenance of +every package and symbol package; the second checks that the SPDX SBOM attestation (predicate +`https://spdx.dev/Document/v2.2`) belongs to that package. `gh release verify` checks the release attestation of the +tag, and `gh release verify-asset` that each file is an asset of that release; `SHA256SUMS` ties the other assets to +it. The workflow runs the same attestation checks twice: in `attest`, before anything is public, and in +`finalize-release`, on the assets it downloads from the published release. + +To tie a plugin to a release, compare its `packages.lock.json` (or the `sha512-…` values of the `CheatEngine.Client*` +and `CheatEngine.SDK` libraries in its deployed `.deps.json`) with the SHA-256 of the attested `.nupkg` and the NuGet +content hash nuget.org serves. `dotnet nuget verify --all ` prints the same content hash for a package +downloaded from nuget.org. + +## Re-run a release + +Use **Re-run failed jobs**. Completed jobs are not repeated and a re-run reuses the artifacts of the original attempt, +so the pushed packages and their attestations stay the same files. `draft-release` always deletes every draft already +on the tag with `gh release delete --yes` and creates it again from the run's artifacts (gh finds a draft by its +tag, deleting a draft release keeps the git tag, and nothing of a draft is public), so a re-run's draft always matches +that run's files. A push of an existing version is skipped as a duplicate, and a published release never receives +assets: `draft-release` fails before anything is created if the release is already published. `finalize-release` +publishes the draft only if it is still a draft; re-run after the publication, it verifies the published release again. +**Re-run all jobs** after any package reached nuget.org stops in `verify`, because the version is already there. + +If `publish` fails while it pushes the symbol packages, the seven packages are already live, and a version on nuget.org +can never be replaced. Re-run only the failed `publish` job (the `nuget` environment asks for approval again): its +`SHA256SUMS` check passes on the same artifacts, the package pushes are skipped as duplicates, and the symbol push runs +again with `--skip-duplicate`, which skips the symbol packages nuget.org already accepted. Never repair symbols with a +new build or a new tag: a new build has different bytes than the packages nuget.org serves. + +A new build of the same tag produces different package bytes (the SBOM of each package has a unique namespace and +creation time). This happens when the maintainer rejects or cancels `publish` and then re-pushes the tag, after moving +it or not, or uses **Re-run all jobs** before any package reached nuget.org: the earlier build's draft is deleted and +replaced by `draft-release`, so the release always carries the packages `publish` pushes and `SHA256SUMS` lists. + +## Next change after the release + +Once the release is public, check the [NuGet packages](https://www.nuget.org/packages/CheatEngine.Client) and the +GitHub release. Then open the next line in one pull request of its own, never in the release pull request (after +1.0.0: the 1.0.0 baseline and the 1.1 floor): + +1. Set `CheatEngineClientPackageValidationBaselineVersion` in [Directory.Build.props](Directory.Build.props) to the + released version (`1.0.0` after the first release), and update the comment of the baseline hook in + `eng/Shipping.props`, which says that nothing is published yet. Package validation then compares every package with + the published package of that version and fails the pack on a breaking change. Two consequences are decided in that + pull request: + - `EnableStrictModeForBaselineValidation` is on (`eng/Shipping.props`), which makes the comparison an equality + check: an API that a 1.1 change adds fails the pack too. Either keep strict mode and record each reviewed addition + in the project's `CompatibilitySuppressions.xml`, or turn strict mode off for the baseline, so that only breaking + changes fail. + - The 1.x policy of the [README](README.md#versioning-and-compatibility) allows changes that package validation + reports even without strict mode: a member added to a call-only interface (`CP0006`), and the removal or change + of an experimental API (`CP0001`, `CP0002`). Each one carries a reviewed suppression, generated with + `-p:GenerateCompatibilitySuppressionFile=true`, in the pull request that makes the change. +2. Raise `MinVerMinimumMajorMinor` to the next line (`1.1` after 1.0.0), so later untagged builds become + `X.(Y+1).0-alpha.0.N`. The evaluation-time `VersionPrefix` follows it and NuGet records it in the project-reference + entries of the lock files, the three coexistence fixture locks included, so regenerate every lock file in the same + pull request as [CONTRIBUTING](CONTRIBUTING.md#lock-files) describes: `dotnet restore --force-evaluate`, + the coexistence fixtures first, one project at a time, never the solution. +3. Reopen `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md): the changes of the next line go under its four headings, + and the section of the released version stays as it was released. +4. Switch the NuGet badge at the top of [README.md](README.md) from `nuget/vpre` to `nuget/v`, so that it shows the + latest stable version. diff --git a/ROADMAP.md b/ROADMAP.md index 41a0c4d..56f1aa5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,13 +1,50 @@ # CheatEngine.Client Roadmap -**Planning baseline: September 21, 2026.** This is an outcome-based plan, not a delivery-date commitment. The initial preparation attempt was denied GitHub writes; the later operator-authorized deployment is recorded in [the engineering evidence receipt](docs/engineering/EVIDENCE_AND_LIMITS.md). The roadmap still does not imply product implementation, package publication, or live-host qualification. +**Planning baseline: September 21, 2026; post-1.0 section written for the 1.0.0 release.** This is an outcome-based plan, not a delivery-date commitment. It does not imply product implementation, package publication, or live-host qualification. ## Operating boundary SDK owns CE integration; Client owns developer-facing workflows. Independent reliability fixes need not wait for all SDK research. Source merged, package shipped, fixture passed and host qualified are separate gates. +## After 1.0.0 + +1.0.0 is the first release; it consumes CheatEngine.SDK 2.0.0. Nothing below has a date or breaks 1.x: a new public API arrives in a 1.x minor release, under the versioning rules of the [README](README.md#versioning-and-compatibility), and a new CheatEngine.SDK major means a Client 2.0. + +### Domains awaiting CheatEngine.SDK primitives + +1.0 has no public contract, not even a gated placeholder, for these Cheat Engine features, because no CheatEngine.SDK primitive owns them: timers; hotkeys; the debugger and breakpoints; the speed hack; target-memory and file hashing; DBVM; remote execution and DLL injection; pausing, resuming or creating a process, and attaching to the foreground process; assembly comments; detaching from a process. Each one can arrive in a 1.x minor release once the consumed CheatEngine.SDK owns it (target binding, cleanup and failure semantics) and its live qualification scenarios exist. The primitive is requested in the CheatEngine.SDK repository first. + +### CheatEngine.SDK requests + +| Request | What the Client needs it for | Until then | +|---|---|---| +| A child-count getter on `MemoryRecord` | `MemoryRecordStateSnapshot.ChildCount` from a typed SDK getter | `TableClient` reads the `Count` property through the untyped `CEObject.TryGetProperty`, the one `AwaitingSdkPrimitive` entry of the architecture ratchet, because `MemoryRecord.TryGetChild(int)` cannot tell an index out of range from a failed read | +| A read status for the `Script` of a memory record | `MemoryRecordContentSnapshot.Script` that tells a record without a script from a failed read | `null` covers both, and a failed `Script` read does not fail the snapshot | +| A protected chunk-execution service | Caller-supplied Lua under the SDK's protection | `UnsafeLuaClient` stays the permanent entry of the ratchet, behind the `EnableUnsafeLuaExecution` opt-in | + +### Leaving experimental + +Each experimental id leaves experimental once every scenario its capability requires succeeds on the exact host tuple, without a waiver ([RELEASING](RELEASING.md#qualification-gate)). An id lifted before 1.0.0 ships stable in 1.0.0; an id still experimental then is lifted in a 1.x minor release, and until that release its API can change or be removed in a minor release. + +| Id | API | Scenarios | +|---|---|---| +| `CECLIENT5001` | Value scans (`IValueScanner`) | Q25, Q26 | +| `CECLIENT5002` | Target allocations (`IAllocationClient`) | Q30.a | +| `CECLIENT5003` | Single-instruction assembly and disassembly (`IAssemblyClient`) | Q32 | +| `CECLIENT5004` | Auto Assembler patches (`IAutoAssemblerClient`, registered by `EnableAutoAssemblerPatches()`) | Q35, Q44 | + +### Lua operations + +Generated Lua operations take scalar inputs and return one result in 1.0 (`CECLUA1103`). CheatEngine.SDK 2.0.0's `LuaOptional` lets a binding omit a trailing argument (`LUA_TNONE`, not an explicit `nil`) and read the number of results Lua actually returned; the Client adopts it once it has a contract for an omitted input and an absent result. + +### Benchmarks + +The facade-versus-direct-SDK comparison (audit item A24-26) is deferred: `tests/CheatEngine.Client.Benchmarks` measures the Client over in-process fakes, and comparing a Client operation with the same direct CheatEngine.SDK call needs a hosted Cheat Engine. + ## Milestones +These milestones and epics are the pre-1.0 plan. Their ids stay stable for the issues that reference them; the [CHANGELOG](CHANGELOG.md) says what 1.0.0 shipped. + | Phase | Outcome | Exit evidence | |---|---|---| | CLI-M0 | Governance and capability truth | Public status separates implementation, artifact, host qualification and policy. | @@ -25,72 +62,72 @@ Maintain the developer-facing support model and evidence-linked execution plan. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-001](docs/engineering/work-items/CLI-001.md) — Reconcile Client audit evidence with source and consumed packages | P1 | Refinement and evidence; no declared issue blocker | -| [CLI-002](docs/engineering/work-items/CLI-002.md) — Separate capability implementation, host evidence, and policy | P1 | CLI-001 | -| [CLI-003](docs/engineering/work-items/CLI-003.md) — Adopt Client governance, roadmap, and issue-to-PR workflow | P1 | Refinement and evidence; no declared issue blocker | +| CLI-001 — Reconcile Client audit evidence with source and consumed packages | P1 | Refinement and evidence; no declared issue blocker | +| CLI-002 — Separate capability implementation, host evidence, and policy | P1 | CLI-001 | +| CLI-003 — Adopt Client governance, roadmap, and issue-to-PR workflow | P1 | Refinement and evidence; no declared issue blocker | ### CLI-E02 — Immediate reliability corrections Repair classified failures, retained contexts, and partial activation cleanup independently of broad SDK expansion. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-004](docs/engineering/work-items/CLI-004.md) — Align expected process failures with production dispatcher semantics | P1 | Refinement and evidence; no declared issue blocker | -| [CLI-005](docs/engineering/work-items/CLI-005.md) — Expire memory-codec contexts after each invocation | P1 | Refinement and evidence; no declared issue blocker | -| [CLI-006](docs/engineering/work-items/CLI-006.md) — Complete all activation rollback stages and preserve original errors | P1 | Refinement and evidence; no declared issue blocker | +| CLI-004 — Align expected process failures with production dispatcher semantics | P1 | Refinement and evidence; no declared issue blocker | +| CLI-005 — Expire memory-codec contexts after each invocation | P1 | Refinement and evidence; no declared issue blocker | +| CLI-006 — Complete all activation rollback stages and preserve original errors | P1 | Refinement and evidence; no declared issue blocker | ### CLI-E03 — SDK contract adoption Replace duplicated built-in CE interpretation with the actual containing SDK artifacts. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-007](docs/engineering/work-items/CLI-007.md) — Replace built-in CE Lua declarations with SDK semantic operations | P1 | SDK-008, SDK-023 | -| [CLI-008](docs/engineering/work-items/CLI-008.md) — Preserve SDK failure provenance and partial effect information | P1 | SDK-007, SDK-023 | -| [CLI-009](docs/engineering/work-items/CLI-009.md) — Bind Client workflows to authoritative SDK target identity | P1 | SDK-010, SDK-011, SDK-023 | +| CLI-007 — Replace built-in CE Lua declarations with SDK semantic operations | P1 | SDK-008, SDK-023 | +| CLI-008 — Preserve SDK failure provenance and partial effect information | P1 | SDK-007, SDK-023 | +| CLI-009 — Bind Client workflows to authoritative SDK target identity | P1 | SDK-010, SDK-011, SDK-023 | ### CLI-E04 — Modules, public boundaries, and activation composition Keep application extensibility safe without leaking raw SDK state or duplicating registration mechanisms. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-010](docs/engineering/work-items/CLI-010.md) — Use SDK registration leases for generated Lua modules | P1 | SDK-009, SDK-022, SDK-023, CLI-006 | -| [CLI-011](docs/engineering/work-items/CLI-011.md) — Enforce recursive public and generated API type boundaries | P1 | Refinement and evidence; no declared issue blocker | -| [CLI-012](docs/engineering/work-items/CLI-012.md) — Specify activation-local DI and multi-plugin composition | P2 | CLI-006, SDK-005 | +| CLI-010 — Use SDK registration leases for generated Lua modules | P1 | SDK-009, SDK-022, SDK-023, CLI-006 | +| CLI-011 — Enforce recursive public and generated API type boundaries | P1 | Refinement and evidence; no declared issue blocker | +| CLI-012 — Specify activation-local DI and multi-plugin composition | P2 | CLI-006, SDK-005 | ### CLI-E05 — Memory, scan, and table workflows Preserve cardinality, partial effects, limits and target consistency across friendly APIs. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-013](docs/engineering/work-items/CLI-013.md) — Make AOB cardinality and result budgets truthful | P1 | SDK-014, SDK-023, CLI-008, CLI-009 | -| [CLI-014](docs/engineering/work-items/CLI-014.md) — Report memory batch effects and cancellation milestones | P1 | SDK-013, CLI-008, CLI-009, CLI-005 | -| [CLI-015](docs/engineering/work-items/CLI-015.md) — Adopt typed record and symbol commands with safe workflow policy | P1 | SDK-021, SDK-023, CLI-008, CLI-009 | +| CLI-013 — Make AOB cardinality and result budgets truthful | P1 | SDK-014, SDK-023, CLI-008, CLI-009 | +| CLI-014 — Report memory batch effects and cancellation milestones | P1 | SDK-013, CLI-008, CLI-009, CLI-005 | +| CLI-015 — Adopt typed record and symbol commands with safe workflow policy | P1 | SDK-021, SDK-023, CLI-008, CLI-009 | ### CLI-E06 — Qualified advanced workflow adoption Enable scan, allocation, patch and event workflows only after their specific lower-layer gates. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-016](docs/engineering/work-items/CLI-016.md) — Adopt qualified SDK value-scan sessions | P2 | SDK-015, SDK-023, CLI-009, CLI-002 | -| [CLI-017](docs/engineering/work-items/CLI-017.md) — Adopt target-bound allocation and patch leases | P1 | SDK-011, SDK-017, SDK-023, CLI-009, CLI-008 | -| [CLI-018](docs/engineering/work-items/CLI-018.md) — Define gated debugger and subscription workflow adapters | P2 | SDK-018, SDK-019, CLI-019, CLI-009 | +| CLI-016 — Adopt qualified SDK value-scan sessions | P2 | SDK-015, SDK-023, CLI-009, CLI-002 | +| CLI-017 — Adopt target-bound allocation and patch leases | P1 | SDK-011, SDK-017, SDK-023, CLI-009, CLI-008 | +| CLI-018 — Define gated debugger and subscription workflow adapters | P2 | SDK-018, SDK-019, CLI-019, CLI-009 | ### CLI-E07 — Developer experience and bounded observation Define coherent lifetime, reader, completion and discoverability policies. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-019](docs/engineering/work-items/CLI-019.md) — Bound event consumers and specify stream completion | P2 | Refinement and evidence; no declared issue blocker | -| [CLI-020](docs/engineering/work-items/CLI-020.md) — Standardize fluent terminals and supported workflow examples | P2 | CLI-002, CLI-013 | -| [CLI-021](docs/engineering/work-items/CLI-021.md) — Unify stale-client and local-process operation semantics | P2 | Refinement and evidence; no declared issue blocker | +| CLI-019 — Bound event consumers and specify stream completion | P2 | Refinement and evidence; no declared issue blocker | +| CLI-020 — Standardize fluent terminals and supported workflow examples | P2 | CLI-002, CLI-013 | +| CLI-021 — Unify stale-client and local-process operation semantics | P2 | Refinement and evidence; no declared issue blocker | ### CLI-E08 — Package, deployment, and release readiness Validate actual packages, external consumers, deployment sets and measured end-to-end behavior. | Work item | Priority | Prerequisites | |---|---|---| -| [CLI-022](docs/engineering/work-items/CLI-022.md) — Validate minimum SDK artifacts and public compatibility | P1 | SDK-023, CLI-007, CLI-010, CLI-011 | -| [CLI-023](docs/engineering/work-items/CLI-023.md) — Verify coherent deployment sets and external plugin inheritance | P2 | CLI-022 | -| [CLI-024](docs/engineering/work-items/CLI-024.md) — Publish end-to-end qualification and performance budgets | P2 | CLI-022, CLI-023, SDK-024 | +| CLI-022 — Validate minimum SDK artifacts and public compatibility | P1 | SDK-023, CLI-007, CLI-010, CLI-011 | +| CLI-023 — Verify coherent deployment sets and external plugin inheritance | P2 | CLI-022 | +| CLI-024 — Publish end-to-end qualification and performance budgets | P2 | CLI-022, CLI-023, SDK-024 | ## Execution notes diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2919421 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security policy + +## Supported versions + +| Version | Supported | Requires CheatEngine.SDK | +|----------------|------------------------------|--------------------------| +| 1.0.x | Yes | `[2.0.0, 3.0.0)` | +| Before 1.0.0 | No: no version was published | Not applicable | + +1.0.0 is the first release. The latest published minor line of the seven `CheatEngine.Client*` packages receives +security fixes; earlier lines do not, so this table moves to 1.1.x when 1.1.0 is published. All seven packages share +one version, so a fix ships as a new version of every package. A fix within 1.x stays on CheatEngine.SDK 2.x: a new +CheatEngine.SDK major means a new Client major version. + +CheatEngine.SDK, which the Client consumes, follows its own policy: +. A plugin references CheatEngine.SDK directly, so it +can take a CheatEngine.SDK 2.x fix without waiting for a Client release: Client 1.0.x accepts every 2.x version at or +above 2.0.0. + +## Reporting a vulnerability + +Report vulnerabilities privately through GitHub private vulnerability reporting: +. + +Never report a vulnerability in a public issue, discussion or pull request. + +Include what is needed to reproduce the problem against an exact release tuple: + +- the CheatEngine.Client version (all `CheatEngine.Client*` packages share it); +- the CheatEngine.SDK version and its `contentHash`, both read from the plugin's `packages.lock.json`; +- the SHA-256 of `cheatengine-sdk-lua-bridge.dll` in the plugin output folder; +- the Cheat Engine version, the name and SHA-256 of the executable you started, and the plugin load profile; +- the impact (what an attacker controls and what they gain) and a minimal reproduction. + +Remove user names and private paths. Never attach Cheat Engine binaries, target binaries, authorization manifests or +raw DebugView dumps. + +## What to expect + +- Acknowledgement within 7 days, on a best-effort basis: the project has a single active maintainer. +- Triage and a coordinated fix through a GitHub Security Advisory on this repository. The advisory credits the reporter + unless they ask otherwise. +- The fix ships as a new package version with a `Security` entry in `CHANGELOG.md`, and the advisory is published when + the fixed packages are available on nuget.org. + +## Scope + +In scope: + +- the seven `CheatEngine.Client*` packages (`CheatEngine.Client`, `.Abstractions`, `.Core`, + `.Extensions.DependencyInjection`, `.Fluent`, `.Hosting`, `.Templates`); +- the content of the `ceplugin` template; +- this repository's build, test and release workflows. + +Out of scope: + +- CheatEngine.SDK: report it privately at + ; +- Cheat Engine itself: report it upstream at ; +- use against processes you are not authorized to inspect or modify. The Client is for local processes you are + authorized to inspect or modify; it adds no network control, no remote transport and no mechanism to bypass Cheat + Engine or host protections; +- behaviour the plugin author opted into: table loading can execute Lua in the host (`AllowedTableRoots` stays empty + unless the plugin has an explicit, trusted import/export location) and arbitrary Lua source is a separate opt-in. + A way to reach either without the opt-in is in scope. diff --git a/eng/CheatEngineSdk.props b/eng/CheatEngineSdk.props new file mode 100644 index 0000000..c1ab5dc --- /dev/null +++ b/eng/CheatEngineSdk.props @@ -0,0 +1,60 @@ + + + + 2.0.0 + 3.0.0 + [$(CheatEngineSdkVersion),$(CheatEngineSdkUpperBound)) + + <_CheatEngineClientSupportedSdkMajor>2 + + diff --git a/eng/Shipping.props b/eng/Shipping.props index 0983b8d..fc9c936 100644 --- a/eng/Shipping.props +++ b/eng/Shipping.props @@ -3,19 +3,56 @@ net10.0 + true + true + true + true + true true + true true true snupkg true true - $(ArtifactsPath)/packages - Fluent, dependency-injection-friendly C# APIs for authorized local Cheat Engine plugins. + + $(CheatEngineClientPackageValidationBaselineVersion) + + $(ArtifactsPath)/nuget cheat-engine;plugin;memory;dotnet;fluent;dependency-injection + + + true + CheatEngineNet + https://github.com/CheatEngineNet/CheatEngine.Client + + + diff --git a/eng/Templates.props b/eng/Templates.props index 1a1c92e..08c51c4 100644 --- a/eng/Templates.props +++ b/eng/Templates.props @@ -2,8 +2,31 @@ net10.0 false - $(ArtifactsPath)/packages - dotnet new template for an in-process, dependency-injection-first Cheat Engine plugin. + + $(ArtifactsPath)/nuget dotnet-new;template;cheat-engine;plugin + + + + true + CheatEngineNet + https://github.com/CheatEngineNet/CheatEngine.Client + + + + + + $(MSBuildProjectDirectory)/content + false + + + + + + diff --git a/eng/Tests.props b/eng/Tests.props index b66e29e..c322e31 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -16,6 +16,12 @@ + + + + diff --git a/global.json b/global.json index 9773a5f..6a239c0 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.Client 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.Client.Abstractions/Allocations/AllocationProtection.cs b/libs/CheatEngine.Client.Abstractions/Allocations/AllocationProtection.cs new file mode 100644 index 0000000..8e643f7 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Allocations/AllocationProtection.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Allocations; + +/// The page protection of a target allocation, fixed when Cheat Engine allocates it. +/// +/// +/// Experimental (CECLIENT5002). The allocation API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// The Client always passes the protection to Cheat Engine's allocateMemory, so an allocation never +/// depends on Cheat Engine's default protection. The protection cannot be changed after the allocation. +/// +/// +[Experimental(ClientExperimentalDiagnostics.Allocations, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public enum AllocationProtection +{ + /// + /// Readable and writable, not executable (PAGE_READWRITE): memory for data exchanged with the target. The + /// default of . + /// + ReadWrite = 0, + + /// + /// Readable, writable and executable (PAGE_EXECUTE_READWRITE): memory for code. It needs no opt-in other than + /// the experimental diagnostic of the allocation API. + /// + ExecuteReadWrite = 1 +} diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/AllocationRequest.cs b/libs/CheatEngine.Client.Abstractions/Allocations/AllocationRequest.cs new file mode 100644 index 0000000..ad5b67d --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Allocations/AllocationRequest.cs @@ -0,0 +1,71 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Allocations; + +/// Describes one allocation of memory in Cheat Engine's selected target. +/// +/// +/// Experimental (CECLIENT5002). The allocation API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// The value has a size of zero: throws an +/// for it, as this constructor does, before the activation check and +/// before any Cheat Engine call. +/// +/// +[Experimental(ClientExperimentalDiagnostics.Allocations, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly record struct AllocationRequest +{ + /// Creates an allocation request. + /// The positive number of bytes to allocate; Cheat Engine may round it up to its page size. + /// The page protection of the allocation. + /// + /// A nonzero target address near which Cheat Engine should allocate, or to let Cheat Engine + /// choose; Cheat Engine may allocate elsewhere. + /// + /// + /// is not positive, is not a defined value, or + /// is the null address. + /// + public AllocationRequest(long size, AllocationProtection protection = AllocationProtection.ReadWrite, + Address? preferredAddress = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size); + if (!Enum.IsDefined(protection)) + { + throw new ArgumentOutOfRangeException(nameof(protection), protection, + "The allocation protection must be a defined value."); + } + + if (preferredAddress is { IsZero: true }) + { + throw new ArgumentOutOfRangeException(nameof(preferredAddress), preferredAddress, + "A preferred address must be nonzero; pass null to let Cheat Engine choose the address."); + } + + Size = size; + Protection = protection; + PreferredAddress = preferredAddress; + } + + /// Gets the positive number of bytes requested from Cheat Engine. + public long Size + { + get; + } + + /// Gets the page protection of the allocation. + public AllocationProtection Protection + { + get; + } + + /// Gets the nonzero target address near which Cheat Engine should allocate, if any. + public Address? PreferredAddress + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs b/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs index 3282469..8482e35 100644 --- a/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs @@ -4,18 +4,69 @@ namespace CheatEngine.Client.Allocations; -/// Creates explicitly owned target-memory allocations. +/// Allocates memory in Cheat Engine's selected target, each allocation owned by a lease. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add members +/// to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5002). The allocation API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// An allocation runs Cheat Engine's allocateMemory on Cheat Engine's main thread through CheatEngine.SDK's +/// allocator, for a target whose identity it could establish; it is refused with +/// otherwise, before any Cheat Engine call. When +/// Cheat Engine allocated but no lease can be published, the Client frees the allocation once: a free that is not +/// confirmed reports and the failure message carries the +/// address, so the memory can be recovered by other means. +/// +/// +[Experimental(ClientExperimentalDiagnostics.Allocations, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public interface IAllocationClient { - /// Tries to allocate bounded target memory for the current selection. + /// Tries to allocate memory in Cheat Engine's selected target. + /// The allocation. + /// The lease of the allocation when the method returns ; release it when done. + /// The classified failure when the method returns . + /// Observed before Cheat Engine allocates, and after. + /// when the memory was allocated and its lease published. + /// + /// A cancellation observed after Cheat Engine allocated frees the allocation at once and publishes nothing. + /// + /// + /// is the request, which has no size, or a tampered one; + /// it is thrown before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping: no new lease is created while it stops. + /// public bool TryAllocate( - TargetAllocationRequest request, + AllocationRequest request, [NotNullWhen(true)] out ITargetMemoryLease? lease, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Allocates bounded target memory or throws when the host rejects the request. + /// Allocates memory in Cheat Engine's selected target, or throws the failure. + /// The allocation. + /// Observed before Cheat Engine allocates, and after. + /// The lease of the allocation; release it when done. + /// + /// is the request, which has no size, or a tampered one; + /// it is thrown before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the allocation failed with + /// . + /// + /// + /// The allocation observed the cancellation of . + /// + /// The allocation failed with any other failure kind. public ITargetMemoryLease Allocate( - TargetAllocationRequest request, + AllocationRequest request, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs b/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs index 82cd6eb..2a50d94 100644 --- a/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs +++ b/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs @@ -1,35 +1,70 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Allocations; -/// Owns one Client-created allocation in the selected target process. +/// Owns one allocation that the Client made in a target process. /// -/// The activation owner releases forgotten leases before disable. A selection-epoch change also invalidates and -/// releases an allocation; implementations must not cache or pool target allocations. +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5002). The allocation API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// Release. frees the allocation on Cheat Engine's main thread, +/// and only in the process, and the process incarnation, that it was made in: when Cheat Engine has selected +/// another process, or when the same process identifier now names another process, CheatEngine.SDK refuses +/// before any Cheat Engine call () and the Client never +/// selects the original process again to free it. The allocation is also refused after a change of the Lua +/// runtime that made it (), and a free that Cheat Engine did +/// not confirm is ; it is never retried. In each of these cases +/// the memory may remain in the target: is +/// , and and stay readable. +/// +/// +/// A release that cannot begin because CheatEngine.SDK detached its runtime is +/// : nothing was freed and CheatEngine.SDK consumed the +/// owner, yet and +/// stay . The lease stays registered, so +/// the deactivation report carries it; retrying the release frees nothing. +/// +/// +/// The lease belongs to the activation and to its target selection: disabling the plugin releases it, and +/// selecting another process ends it with a release that is refused, as above, which leaves the memory in the +/// previous process. Release an allocation before selecting another process, and do not cache or pool +/// allocations across selections. +/// /// -public interface ITargetMemoryLease : IDisposable +[Experimental(ClientExperimentalDiagnostics.Allocations, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public interface ITargetMemoryLease : ICheatEngineLease { - /// Gets the allocated target address. + /// Gets the target address that Cheat Engine allocated; it stays readable after the release. public Address Address { get; } - /// Gets the allocated byte count. + /// Gets the number of bytes that was requested, which the release passes back to Cheat Engine. public long Size { get; } - /// Gets the target-selection epoch captured when this allocation was created. - public long SelectionEpoch + /// Gets the page protection the allocation was made with. + public AllocationProtection Protection { get; } - /// Gets whether the allocation has been released or invalidated. - public bool IsReleased + /// Gets the target-selection epoch of the process the allocation was made in. + public long SelectionEpoch { get; } + } diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs b/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs deleted file mode 100644 index 71018c4..0000000 --- a/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CheatEngine.Client.Allocations; - -/// Describes the access required by a Client-owned target-memory allocation. -public enum TargetAllocationAccess -{ - /// Creates memory for copied parameter and data exchange. - ReadWrite, - - /// Creates executable memory when a capability-gated operation has proven that requirement. - ExecuteReadWrite -} diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs b/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs deleted file mode 100644 index 5f8ca12..0000000 --- a/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CheatEngine.Client.Allocations; - -/// Describes one bounded allocation requested in the currently selected target. -public readonly record struct TargetAllocationRequest -{ - /// Creates a target allocation request. - /// is not positive. - /// is not a defined value. - public TargetAllocationRequest(long size, TargetAllocationAccess access = TargetAllocationAccess.ReadWrite) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size); - if (!Enum.IsDefined(access)) - { - throw new ArgumentOutOfRangeException(nameof(access)); - } - - Size = size; - Access = access; - } - - /// Gets the exact positive byte count requested from the target process. - public long Size - { - get; - } - - /// Gets the requested target-memory access. - public TargetAllocationAccess Access - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs index 69d430e..29a8870 100644 --- a/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs @@ -1,17 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Assembly; /// Describes a single instruction to assemble at one target address. +/// +/// The address is the explicit origin Cheat Engine receives: the same text assembles to different bytes for another +/// origin, preference or range-check option, so reuse the bytes only at the address they were assembled for. +/// +[Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public readonly record struct AssemblyInstructionRequest { /// Creates an instruction-assembly request. + /// The target address used as the origin of relative operands. + /// The source of exactly one instruction, sent to Cheat Engine without normalization. + /// The jump and call encoding Cheat Engine should prefer. + /// + /// Whether Cheat Engine skips its check that a relative operand is reachable from . + /// When , Cheat Engine emits bytes even when they cannot encode the intended target, and + /// the caller owns that risk. + /// /// is blank. - public AssemblyInstructionRequest(Address address, string instruction) + /// + /// is not a defined value. + /// + public AssemblyInstructionRequest(Address address, string instruction, + InstructionEncodingPreference preference = InstructionEncodingPreference.None, bool skipRangeCheck = false) { ArgumentException.ThrowIfNullOrWhiteSpace(instruction); + if (preference is < InstructionEncodingPreference.None or > InstructionEncodingPreference.Far) + { + throw new ArgumentOutOfRangeException(nameof(preference), preference, + "The encoding preference must be None, Short, Long or Far."); + } + Address = address; Instruction = instruction; + Preference = preference; + SkipRangeCheck = skipRangeCheck; } /// Gets the address used as the instruction's assembly origin. @@ -25,4 +52,16 @@ public string Instruction { get; } + + /// Gets the jump and call encoding Cheat Engine should prefer. + public InstructionEncodingPreference Preference + { + get; + } + + /// Gets whether Cheat Engine skips its reachability check of relative operands. + public bool SkipRangeCheck + { + get; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs index 09c3a97..79b3225 100644 --- a/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs @@ -1,24 +1,51 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Assembly; -/// A copied disassembly instruction and its exact target byte representation. -public readonly record struct AssemblyInstructionSnapshot +/// A copied disassembled instruction and the exact target bytes it was decoded from. +/// +/// +/// , and are the columns Cheat Engine's +/// disassembler returns, copied as text; they are display text and the Client never parses them. +/// joins and . +/// +/// +/// are read from target memory for bytes, the instruction size Cheat +/// Engine reports; they are never parsed from the disassembler's byte column. +/// +/// +[Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly struct AssemblyInstructionSnapshot { + private readonly string? _addressText; + private readonly ImmutableArray _bytes; + private readonly string? _extra; + private readonly string? _opcode; + private readonly string? _text; + /// Creates a copied assembly-instruction snapshot. + /// The address of the instruction. + /// The exact positive instruction length. + /// The address column of Cheat Engine's disassembler. + /// The mnemonic and operands column of Cheat Engine's disassembler. + /// The annotation column of Cheat Engine's disassembler; empty when there is none. + /// The target bytes of the instruction; exactly bytes. /// is not positive. - /// is blank or is empty. - public AssemblyInstructionSnapshot(Address address, int length, string text, ReadOnlySpan bytes) + /// or is null. + /// + /// is blank, or does not hold exactly + /// bytes. + /// + public AssemblyInstructionSnapshot(Address address, int length, string addressText, string opcode, string extra, + ReadOnlySpan bytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); - ArgumentException.ThrowIfNullOrWhiteSpace(text); - if (bytes.IsEmpty) - { - throw new ArgumentException("An instruction snapshot requires at least one byte.", nameof(bytes)); - } - + ArgumentNullException.ThrowIfNull(addressText); + ArgumentException.ThrowIfNullOrWhiteSpace(opcode); + ArgumentNullException.ThrowIfNull(extra); if (bytes.Length != length) { throw new ArgumentException("The byte count must match the instruction length.", nameof(bytes)); @@ -26,8 +53,11 @@ public AssemblyInstructionSnapshot(Address address, int length, string text, Rea Address = address; Length = length; - Text = text; - Bytes = ImmutableArray.Create(bytes); + _addressText = addressText; + _opcode = opcode; + _extra = extra; + _text = string.IsNullOrWhiteSpace(extra) ? opcode : opcode + " " + extra; + _bytes = ImmutableArray.Create(bytes); } /// Gets the address of the instruction. @@ -42,15 +72,26 @@ public int Length get; } - /// Gets the copied disassembly text. - public string Text - { - get; - } + /// Gets the copied address column of Cheat Engine's disassembler. + /// for the value. + public string AddressText => _addressText ?? string.Empty; - /// Gets the immutable copy of the target instruction bytes. - public ImmutableArray Bytes - { - get; - } + /// Gets the copied mnemonic and operands column of Cheat Engine's disassembler. + /// for the value. + public string Opcode => _opcode ?? string.Empty; + + /// Gets the copied annotation column of Cheat Engine's disassembler; empty when there is none. + /// for the value. + public string Extra => _extra ?? string.Empty; + + /// + /// Gets the instruction as one line: , followed by a space and when + /// is not blank. + /// + /// for the value. + public string Text => _text ?? string.Empty; + + /// Gets the immutable copy of the target instruction bytes, read from target memory. + /// Empty for the value, never a default array. + public ImmutableArray Bytes => _bytes.IsDefault ? ImmutableArray.Empty : _bytes; } diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerCheckResult.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerCheckResult.cs new file mode 100644 index 0000000..6f911bc --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerCheckResult.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Assembly; + +/// The copied verdict of one Auto Assembler syntax check (). +/// +/// is Cheat Engine's bounded, unparsed error text for a rejected section, when Cheat +/// Engine returned one. It is user data (it can contain script source and file paths), so +/// omits it. +/// +[Experimental(ClientExperimentalDiagnostics.AutoAssemblerPatches, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly record struct AutoAssemblerCheckResult +{ + /// Creates a check verdict. + /// Whether Cheat Engine accepted the checked section. + /// Cheat Engine's bounded error text, or . + /// Whether was cut at the Client's bound. + /// + /// is without . + /// + public AutoAssemblerCheckResult(bool isAccepted, string? hostMessages, bool hostMessagesTruncated) + { + if (hostMessagesTruncated && hostMessages is null) + { + throw new ArgumentException("Truncated host messages require the copied text.", + nameof(hostMessagesTruncated)); + } + + IsAccepted = isAccepted; + HostMessages = hostMessages; + HostMessagesTruncated = hostMessagesTruncated; + } + + /// Gets whether Cheat Engine accepted the checked section. + /// An accepted section does not prove that an activation will succeed. + public bool IsAccepted + { + get; + } + + /// Gets Cheat Engine's bounded, unparsed error text, or when it returned none. + public string? HostMessages + { + get; + } + + /// Gets whether was cut at the Client's host-text bound. + public bool HostMessagesTruncated + { + get; + } + + /// Returns the verdict only, never the host messages. + /// Accepted or Rejected. + public override string ToString() + { + return IsAccepted ? "Accepted" : "Rejected"; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs index f3136cd..c775808 100644 --- a/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs @@ -1,9 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + namespace CheatEngine.Client.Assembly; -/// Describes an Auto Assembler script supplied to the capability-gated high-level API. +/// Describes an Auto Assembler script supplied to . +[Experimental(ClientExperimentalDiagnostics.AutoAssemblerPatches, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public readonly record struct AutoAssemblerScript { /// Creates an Auto Assembler script request. + /// The complete Auto Assembler source. + /// A diagnostic name for the patch, or for none. + /// is . /// is blank or is blank. public AutoAssemblerScript(string source, string? name = null) { diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs b/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs index 08e1fde..313612f 100644 --- a/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs @@ -6,50 +6,158 @@ namespace CheatEngine.Client.Assembly; -/// Disassembles, assembles, comments, and applies Client-owned Auto Assembler patches. +/// Assembles, disassembles and measures single target instructions. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5003). The instruction API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// One profile per call. Each call observes the selected target and its instruction profile (x86, x64, +/// ARM32 or ARM64 with its address width) once, in the same dispatched callback as its Cheat Engine calls, and +/// never reselects or reconfigures anything. An address wider than the observed profile is refused with +/// and +/// before any instruction function of Cheat Engine is called. A step refused after an earlier instruction call +/// of the same Client call returned (the disassembly and its byte read follow the length query) is +/// , never . +/// CheatEngine.SDK checks the selected process again before and after every Cheat Engine call; a target that +/// changed meanwhile is , and nothing is returned. That +/// check is an observation, not a lock. +/// +/// +/// Bounds. Assembled bytes and the bytes of a disassembled instruction are copied up to +/// MemoryResourceLimits.MaximumReadBytes, the disassembler's text up to +/// MemoryResourceLimits.MaximumStringBytes UTF-8 bytes; a larger result is +/// . No operation writes target memory. +/// +/// +/// A cancellation token is observed only before the work is dispatched to Cheat Engine's main thread +/// ( with ). +/// Auto Assembler patches are not part of this client: they are applied through +/// , which the activation registers only when it opts in. +/// +/// +[Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public interface IAssemblyClient { - /// Tries to copy one instruction from the target. - public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Copies one target instruction or throws when disassembly fails. - public AssemblyInstructionSnapshot Disassemble(Address address, CancellationToken cancellationToken = default); - - /// Tries to get the exact size of the instruction at an address. - public bool TryGetInstructionSize(Address address, out int size, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Gets the instruction size or throws when Cheat Engine cannot decode it. - public int GetInstructionSize(Address address, CancellationToken cancellationToken = default); - - /// Tries to resolve the start address of the preceding target instruction. - public bool TryGetPreviousInstruction(Address address, out Address previousAddress, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Gets the preceding instruction address or throws when it cannot be resolved. - public Address GetPreviousInstruction(Address address, CancellationToken cancellationToken = default); - - /// Tries to copy the optional comment associated with one target address. - public bool TryGetComment(Address address, [NotNullWhen(true)] out string? comment, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Gets an address comment or throws when no comment is available. - public string GetComment(Address address, CancellationToken cancellationToken = default); - /// Tries to assemble exactly one instruction into copied bytes. + /// The instruction, its origin address, its encoding preference and range-check option. + /// The assembled bytes on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before dispatch only. + /// when Cheat Engine assembled the instruction. + /// is the default value. + /// + /// Cheat Engine's rejection of the instruction (an unknown mnemonic or a missing symbol) is + /// with . + /// An empty result is : one instruction is never zero bytes + /// long. + /// + /// The activation has ended. + /// The activation is stopping. public bool TryAssemble(AssemblyInstructionRequest request, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Assembles exactly one instruction into copied bytes or throws when assembly fails. + /// The instruction, its origin address, its encoding preference and range-check option. + /// Observed before dispatch only. + /// The assembled bytes. + /// is the default value. + /// The activation has ended. + /// + /// The activation is stopping, or the assembly failed with . + /// + /// + /// The assembly observed the cancellation of . + /// + /// + /// The assembly failed with any other failure kind, a rejected instruction included. + /// public ImmutableArray Assemble(AssemblyInstructionRequest request, CancellationToken cancellationToken = default); - /// Tries to apply a Client-owned Auto Assembler script. - public bool TryApplyPatch(AutoAssemblerScript script, [NotNullWhen(true)] out IAutoAssemblerPatchLease? lease, + /// Tries to disassemble the instruction at an address, with its bytes read from target memory. + /// The address of the instruction. + /// The copied instruction on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before dispatch only. + /// when the instruction, its length and its bytes were copied. + /// The activation has ended. + /// The activation is stopping. + public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Applies a Client-owned Auto Assembler script or throws when the host rejects it. - public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, + /// Disassembles the instruction at an address or throws when disassembly fails. + /// The address of the instruction. + /// Observed before dispatch only. + /// The copied instruction. + /// The activation has ended. + /// + /// The activation is stopping, or the disassembly failed with + /// . + /// + /// + /// The disassembly observed the cancellation of . + /// + /// The disassembly failed with any other failure kind. + public AssemblyInstructionSnapshot Disassemble(Address address, CancellationToken cancellationToken = default); + + /// Tries to get the exact length of the instruction at an address. + /// The address of the instruction. + /// The positive length on success; otherwise zero. + /// The classified failure; the default value on success. + /// Observed before dispatch only. + /// when Cheat Engine reported a positive length. + /// The activation has ended. + /// The activation is stopping. + public bool TryGetInstructionLength(Address address, out int length, out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Gets the exact length of the instruction at an address or throws when Cheat Engine cannot decode it. + /// The address of the instruction. + /// Observed before dispatch only. + /// The positive instruction length. + /// The activation has ended. + /// + /// The activation is stopping, or the query failed with . + /// + /// + /// The query observed the cancellation of . + /// + /// The query failed with any other failure kind. + public int GetInstructionLength(Address address, CancellationToken cancellationToken = default); + + /// Tries to get Cheat Engine's estimate of the start address of the preceding instruction. + /// The address of the instruction that follows the one sought. + /// The estimated start address on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before dispatch only. + /// when Cheat Engine returned an address within the target's address width. + /// + /// Variable-length instructions cannot in general be decoded backwards: the result is Cheat Engine's estimate, + /// not a proof. An estimate wider than the target's address width is + /// with . + /// + /// The activation has ended. + /// The activation is stopping. + public bool TryGetPreviousInstructionAddress(Address address, out Address previousAddress, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Gets Cheat Engine's estimate of the preceding instruction address or throws when it has none. + /// The address of the instruction that follows the one sought. + /// Observed before dispatch only. + /// The estimated start address of the preceding instruction. + /// The activation has ended. + /// + /// The activation is stopping, or the query failed with . + /// + /// + /// The query observed the cancellation of . + /// + /// The query failed with any other failure kind. + public Address GetPreviousInstructionAddress(Address address, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerClient.cs b/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerClient.cs new file mode 100644 index 0000000..fda237e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerClient.cs @@ -0,0 +1,129 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Assembly; + +/// Checks Auto Assembler scripts and applies them as Client-owned patch leases. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5004). The Auto Assembler API can change in a minor release until its +/// live scenarios pass; see the Abstractions README. +/// +/// +/// Explicit opt-in. The dependency-injection integration registers this client only when the activation +/// calls CheatEngineClientBuilder.EnableAutoAssemblerPatches(); it is never a property of +/// . Without the opt-in no implementation is registered, and the +/// Client.AutoAssemblerPatches capability reports a Missing policy gate. An Auto Assembler script +/// can allocate memory, inject code and run Lua in Cheat Engine: pass only scripts your plugin owns. +/// +/// +/// applies the script once through Cheat Engine's autoAssemble and returns +/// the only owner of the disable information Cheat Engine returned. Releasing the lease runs the script's +/// [DISABLE] section once with that information, on the target the patch was applied to; the Client never +/// rebuilds a [DISABLE] section and never retries a disable that began. +/// +/// +/// Release every patch lease before selecting another process. The lease is bound to the target +/// selection of the process CheatEngine.SDK applied the patch in, and the Client observes a selection change +/// only after Cheat Engine already targets the new process. It then ends the lease with +/// : CheatEngine.SDK refuses the disable on the new target +/// but consumes the disable information, so the patch stays in the previous process, +/// is , and selecting the +/// previous process again cannot disable it. +/// +/// +/// A cancellation token is observed only before the work is dispatched to Cheat Engine's main thread +/// ( with ): it never +/// interrupts or undoes a check or an activation that began. The host messages and warnings Cheat Engine returns +/// are bounded copies, never parsed; like , they can contain script text +/// and are user data. +/// +/// +[Experimental(ClientExperimentalDiagnostics.AutoAssemblerPatches, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public interface IAutoAssemblerClient +{ + /// Tries to check the [ENABLE] section of a script without applying it. + /// The script to check. + /// + /// Whether Cheat Engine accepted the section, with its bounded messages when it did not; the default value when the + /// check could not run. + /// + /// The reason the check could not run; the default value on success. + /// Observed before the check is dispatched. + /// + /// when Cheat Engine reported a verdict, including a rejection + /// ( is then ). + /// + /// is the default value. + /// + /// An accepted check does not prove that an activation will succeed: the target or its symbols can change before + /// it, and allocations or injections are not attempted. A check never creates a lease. + /// + /// The activation has ended. + /// The activation is stopping. + public bool TryCheck(AutoAssemblerScript script, out AutoAssemblerCheckResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Checks the [ENABLE] section of a script, or throws when the check could not run. + /// The script to check. + /// Observed before the check is dispatched. + /// Whether Cheat Engine accepted the section, with its bounded messages when it did not. + /// is the default value. + /// The activation has ended. + /// + /// The activation is stopping, or the check could not run with + /// . + /// + /// The check was cancelled before it was dispatched. + /// + /// The check could not run, with any other failure kind; a rejection is a result, not an exception. + /// + public AutoAssemblerCheckResult Check(AutoAssemblerScript script, CancellationToken cancellationToken = default); + + /// Tries to apply a script and to take ownership of the patch Cheat Engine applied. + /// The complete script, with its [ENABLE] and [DISABLE] sections. + /// The owner of the applied patch on success; otherwise . + /// The reason no lease was returned; the default value on success. + /// Observed before the activation is dispatched. + /// when Cheat Engine applied the script and the Client owns the patch. + /// is the default value. + /// + /// A rejection () does not prove that nothing changed: a + /// script can apply part of its effects before it fails, so its host effect is + /// and Cheat Engine's bounded error text is in + /// . When Cheat Engine applied the script but the selected target no + /// longer matched right after, the lease is still returned with + /// set, and the Client logs a warning. A + /// script applied without an owner (), and a + /// failed activation whose owner was released incompletely, report + /// : the patch may remain in the target. + /// + /// The activation has ended. + /// + /// The activation is stopping: no new lease is created while it stops. + /// + public bool TryApplyPatch(AutoAssemblerScript script, [NotNullWhen(true)] out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Applies a script and returns the owner of the patch, or throws when no lease can be returned. + /// The complete script, with its [ENABLE] and [DISABLE] sections. + /// Observed before the activation is dispatched. + /// The owner of the applied patch. + /// is the default value. + /// The activation has ended. + /// + /// The activation is stopping, or no lease was returned, with + /// . + /// + /// The activation was cancelled before it was dispatched. + /// + /// No lease was returned, with any other failure kind, a rejection of the script included. + /// + public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs b/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs index 5535d28..bcee1d5 100644 --- a/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs +++ b/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs @@ -1,23 +1,100 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + namespace CheatEngine.Client.Assembly; -/// Owns one Client-applied Auto Assembler patch for the current activation and target selection. -public interface IAutoAssemblerPatchLease : IDisposable +/// Owns one Auto Assembler patch the Client applied for the current activation and target selection. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5004). The Auto Assembler API can change in a minor release until its +/// live scenarios pass; see the Abstractions README. +/// +/// +/// The lease holds the only disable information Cheat Engine returned for the patch. The first release attempt +/// that reaches Cheat Engine consumes it: CheatEngine.SDK validates that the target the patch was applied to is +/// still selected, then runs the script's [DISABLE] section once with that information. A release on +/// another target is refused () without selecting a process; +/// a release after Cheat Engine's Lua runtime detached or replaced its Lua state, or one that could not begin +/// the disable, is refused (); and a disable that Cheat +/// Engine did not confirm is . In each case the attempt ended +/// the lease, the patch may remain in the target, and +/// becomes : no later release can +/// disable it. Before any release, is the early signal: it is +/// once Cheat Engine's Lua runtime detached or replaced its Lua state, while +/// stays until a release +/// reports what it left. +/// +/// +/// Release the lease before selecting another process. The Client observes a selection change only after +/// Cheat Engine already targets the new process, and then ends the lease with +/// : the disable information is consumed, the patch +/// stays in the previous process, and selecting that process again cannot disable it. +/// +/// +/// The Client never rebuilds a [DISABLE] section, and it does not expose Cheat Engine's disable +/// information (allocations, registered symbols): that table stays the only disable authority. +/// +/// +[Experimental(ClientExperimentalDiagnostics.AutoAssemblerPatches, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public interface IAutoAssemblerPatchLease : ICheatEngineLease { - /// Gets the Client diagnostic name supplied when the patch was applied. + /// Gets the Client diagnostic name supplied with the script, if any. public string? Name { get; } - /// Gets the target-selection epoch captured by this patch. + /// Gets the target-selection epoch of the process CheatEngine.SDK applied the patch in. public long SelectionEpoch { get; } - /// Gets whether the patch has already executed its disable cleanup or was invalidated. - public bool IsReleased + /// + /// Gets whether a release can still run the [DISABLE] section: the lease holds Cheat Engine's disable + /// information in the current Lua state. + /// + /// + /// once a release attempt consumed the information: read + /// to know whether that attempt disabled the patch. Before any + /// release, means that Cheat Engine's Lua state was detached or replaced: a release that + /// reaches CheatEngine.SDK cannot run [DISABLE], is refused + /// () and leaves the patch in the target. + /// + public bool CanDisable + { + get; + } + + /// + /// Gets whether Cheat Engine applied the script while the selected target changed: the target observed right after + /// the activation no longer matched the one observed before it. + /// + /// + /// The patch stays bound to the target observed before the activation, so its release is refused while another + /// process is selected. Treat its effect on either process as uncertain. + /// + public bool AppliedAfterTargetChange { get; } + + /// Gets Cheat Engine's bounded, unparsed compilation warnings for the activation, if it returned any. + /// User data: the text can contain script source and file paths. + public string? HostWarnings + { + get; + } + + /// Gets whether was cut at the Client's host-text bound. + public bool HostWarningsTruncated + { + get; + } + } diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/InstructionEncodingPreference.cs b/libs/CheatEngine.Client.Abstractions/Assembly/InstructionEncodingPreference.cs new file mode 100644 index 0000000..49dd1f5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/InstructionEncodingPreference.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Assembly; + +/// The jump and call encoding that Cheat Engine should prefer when it assembles one instruction. +/// +/// +/// An option enum: (0) is the default and lets Cheat Engine choose the encoding. The other +/// values are passed to Cheat Engine's assemble unchanged through CheatEngine.SDK, as Cheat Engine's own +/// apShort, apLong and apFar preferences. A preference only changes the encoding Cheat +/// Engine picks for a relative jump or call; it never reconfigures Cheat Engine's assembler. +/// +/// Values are stable; a minor release can add one. +/// +[Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +[SuppressMessage("Naming", "CA1720:Identifiers should not contain type names", + Justification = "The members mirror Cheat Engine's apShort, apLong and apFar jump preferences.")] +public enum InstructionEncodingPreference +{ + /// No preference: Cheat Engine chooses the encoding. + None = 0, + + /// Prefer the short encoding of a relative jump or call. + Short = 1, + + /// Prefer the long encoding of a relative jump or call. + Long = 2, + + /// Prefer the far encoding of a jump or call. + Far = 3 +} diff --git a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj index 294c798..e46d5da 100644 --- a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj +++ b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj @@ -2,11 +2,12 @@ CheatEngine.Client + Contracts, requests, failures and value vocabulary of CheatEngine.Client: the public types a Cheat Engine plugin or library references without taking the implementation. diff --git a/libs/CheatEngine.Client.Abstractions/ClientExperimentalDiagnostics.cs b/libs/CheatEngine.Client.Abstractions/ClientExperimentalDiagnostics.cs new file mode 100644 index 0000000..15fce95 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/ClientExperimentalDiagnostics.cs @@ -0,0 +1,43 @@ +namespace CheatEngine.Client; + +/// The diagnostic identifiers of the Client's experimental APIs and the address of their documentation. +/// +/// +/// An experimental API is marked [Experimental(id, UrlFormat = UrlFormat)]: using it raises the +/// diagnostic id, which the consumer suppresses to opt in. Its entries in PublicAPI.Unshipped.txt +/// carry the [id] prefix, and the Abstractions README has an anchor named after the id that states the scope +/// and the exit criteria (ClientExperimentalDiagnosticsTests keeps the three in agreement). +/// +/// +/// Every id is declared here once. The dependency-injection package compiles this file as a link for its opt-in, +/// and Core and dependency injection suppress every id in their project file, never file by file. +/// +/// +/// An id is removed, with its attributes and prefixes, only when every live scenario of its capability passes on +/// the exact qualified tuple. +/// +/// +internal static class ClientExperimentalDiagnostics +{ + /// The documentation address of every experimental API; {0} is the diagnostic id. + internal const string UrlFormat = + "https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md#{0}"; + + /// Value scans over Cheat Engine scan sessions (IValueScanner and its types). + internal const string ValueScans = "CECLIENT5001"; + + /// Target allocations over CheatEngine.SDK's allocator (IAllocationClient and its types). + internal const string Allocations = "CECLIENT5002"; + + /// + /// Single-instruction assembly, disassembly and length operations (ICheatEngineClient.Assembly, + /// IAssemblyClient and its types). + /// + internal const string Instructions = "CECLIENT5003"; + + /// + /// Auto Assembler patches (IAutoAssemblerClient, its types and the dependency-injection opt-in + /// EnableAutoAssemblerPatches). + /// + internal const string AutoAssemblerPatches = "CECLIENT5004"; +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs deleted file mode 100644 index feb9ab0..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CheatEngine.Client.Dbvm; - -/// Describes an explicit DBVM initialization request. -public readonly record struct DbvmInitializationRequest -{ - /// Creates a DBVM initialization request. - public DbvmInitializationRequest(bool useStealthMode = false) - { - UseStealthMode = useStealthMode; - } - - /// Gets whether the caller explicitly requests the host's documented stealth mode. - public bool UseStealthMode - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs deleted file mode 100644 index 3c57db8..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CheatEngine.Client.Dbvm; - -/// Describes the observed DBVM lifecycle without triggering initialization. -public enum DbvmState -{ - /// No host observation has established the DBVM state. - Unknown, - - /// The active host cannot expose DBVM operations. - Unavailable, - - /// DBVM is supported but has not been initialized. - NotInitialized, - - /// DBVM has been initialized explicitly for the active host. - Initialized -} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs deleted file mode 100644 index 2a6c742..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CheatEngine.Client.Dbvm; - -/// Contains a copied DBVM status observation. -public readonly record struct DbvmStatusSnapshot -{ - /// Creates a copied DBVM status observation. - /// is not a defined value. - public DbvmStatusSnapshot(DbvmState state, string? version = null) - { - if (!Enum.IsDefined(state)) - { - throw new ArgumentOutOfRangeException(nameof(state)); - } - - if (version is not null && string.IsNullOrWhiteSpace(version)) - { - throw new ArgumentException("A DBVM version must be null or non-empty.", nameof(version)); - } - - State = state; - Version = version; - } - - /// Gets the observed state without causing initialization. - public DbvmState State - { - get; - } - - /// Gets the optional copied DBVM version reported by the host. - public string? Version - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs deleted file mode 100644 index 1f4e705..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Dbvm; - -/// Contains a copied DBVM watch observation. -public readonly record struct DbvmWatchEvent -{ - /// Creates a copied DBVM watch observation. - /// is not positive. - public DbvmWatchEvent(Address address, int length, DateTimeOffset occurredAt) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); - Address = address; - Length = length; - OccurredAt = occurredAt; - } - - /// Gets the copied target address that triggered the watch. - public Address Address - { - get; - } - - /// Gets the copied positive byte count associated with the event. - public int Length - { - get; - } - - /// Gets the copied observation timestamp. - public DateTimeOffset OccurredAt - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs deleted file mode 100644 index 9933c3b..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CheatEngine.Client.Dbvm; - -/// Handles a copied DBVM watch event synchronously without blocking the host callback thread. -public delegate void DbvmWatchHandler(DbvmWatchEvent watchEvent); diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs deleted file mode 100644 index 0cc79e0..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Dbvm; - -/// Describes a bounded DBVM watch over one target-memory range. -public readonly record struct DbvmWatchRequest -{ - /// Creates a bounded DBVM watch request. - /// is not positive. - public DbvmWatchRequest(Address address, int length) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); - Address = address; - Length = length; - } - - /// Gets the first target address covered by the watch. - public Address Address - { - get; - } - - /// Gets the exact positive watched byte count. - public int Length - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs deleted file mode 100644 index cd44597..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Dbvm; - -/// Observes DBVM state and exposes explicit initialization and watch operations. -public interface IDbvmClient -{ - /// Tries to observe DBVM state without initializing DBVM. - public bool TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Observes DBVM state without initializing DBVM. - public DbvmStatusSnapshot GetStatus(CancellationToken cancellationToken = default); - - /// Tries to initialize DBVM only after an explicit caller request. - public bool TryInitialize(DbvmInitializationRequest request, out DbvmStatusSnapshot status, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Initializes DBVM only after an explicit caller request. - public DbvmStatusSnapshot Initialize(DbvmInitializationRequest request, - CancellationToken cancellationToken = default); - - /// Tries to register a DBVM watch and its bounded copied event stream. - public bool TryRegisterWatch( - DbvmWatchRequest request, - DbvmWatchHandler handler, - EventStreamOptions streamOptions, - [NotNullWhen(true)] out IDbvmWatchLease? lease, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Registers a DBVM watch or throws when the capability is unavailable or registration fails. - public IDbvmWatchLease RegisterWatch( - DbvmWatchRequest request, - DbvmWatchHandler handler, - EventStreamOptions streamOptions, - CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs deleted file mode 100644 index a8ad5ac..0000000 --- a/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Dbvm; - -/// Owns one Client DBVM watch and its bounded copied event stream. -public interface IDbvmWatchLease : IEventStreamLease -{ - /// Gets the request used to create this watch. - public DbvmWatchRequest Request - { - get; - } - - /// Gets the target-selection epoch captured when the watch was registered. - public long SelectionEpoch - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs deleted file mode 100644 index 2bb23d3..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CheatEngine.Client.Debugger; - -/// Specifies the immediate continuation decision returned by a synchronous breakpoint handler. -public enum BreakpointDisposition -{ - /// Continues target execution after the synchronous handler returns. - Continue, - - /// Leaves execution broken for the host's debugger workflow. - Break -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs deleted file mode 100644 index 0630b8a..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Immutable; - -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Debugger; - -/// Contains copied data delivered synchronously for one breakpoint hit. -public readonly record struct BreakpointEvent -{ - /// Creates a copied breakpoint event. - /// is negative. - public BreakpointEvent(Address address, int threadId, ImmutableArray registers) - { - ArgumentOutOfRangeException.ThrowIfNegative(threadId); - if (registers.IsDefault) - { - throw new ArgumentException("Register snapshots must be initialized.", nameof(registers)); - } - - Address = address; - ThreadId = threadId; - Registers = registers; - } - - /// Gets the copied instruction address that triggered the breakpoint. - public Address Address - { - get; - } - - /// Gets the non-negative target thread identifier. - public int ThreadId - { - get; - } - - /// Gets immutable copied register observations. - public ImmutableArray Registers - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs deleted file mode 100644 index 05ccfa3..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CheatEngine.Client.Debugger; - -/// Handles one copied breakpoint event without awaiting on a Cheat Engine callback thread. -public delegate BreakpointDisposition BreakpointHandler(BreakpointEvent breakpointEvent); diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs deleted file mode 100644 index 806436e..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CheatEngine.Client.Debugger; - -/// Specifies the target-memory access that triggers a breakpoint. -public enum BreakpointKind -{ - /// Breaks when execution reaches the target address. - Execute, - - /// Breaks when the target address is written. - Write, - - /// Breaks when the target address is read or written. - Access -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs deleted file mode 100644 index 5d50206..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace CheatEngine.Client.Debugger; - -/// Contains one copied register observation associated with a breakpoint event. -public readonly record struct BreakpointRegisterSnapshot -{ - /// Creates a copied register observation. - /// is blank. - public BreakpointRegisterSnapshot(string name, ulong value) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - Name = name; - Value = value; - } - - /// Gets the normalized register name. - public string Name - { - get; - } - - /// Gets the copied register machine value. - public ulong Value - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs deleted file mode 100644 index e3890d3..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Debugger; - -/// Describes one Client-owned breakpoint. -public readonly record struct BreakpointRequest -{ - /// Creates a breakpoint request. - /// is not a defined value. - public BreakpointRequest(Address address, BreakpointKind kind = BreakpointKind.Execute) - { - if (!Enum.IsDefined(kind)) - { - throw new ArgumentOutOfRangeException(nameof(kind)); - } - - Address = address; - Kind = kind; - } - - /// Gets the target address observed by the breakpoint. - public Address Address - { - get; - } - - /// Gets the access kind that triggers the breakpoint. - public BreakpointKind Kind - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs b/libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs deleted file mode 100644 index 9a0e123..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Debugger; - -/// Owns one Client breakpoint and its bounded copied event stream. -public interface IBreakpointLease : IEventStreamLease -{ - /// Gets the request used to create this breakpoint. - public BreakpointRequest Request - { - get; - } - - /// Gets the target-selection epoch captured when the breakpoint was registered. - public long SelectionEpoch - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs b/libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs deleted file mode 100644 index 53d2cc7..0000000 --- a/libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Debugger; - -/// Registers Client-owned breakpoints with immediate handler decisions and bounded copied event streams. -public interface IDebuggerClient -{ - /// Tries to register one breakpoint for the current activation and selection. - public bool TryRegisterBreakpoint( - BreakpointRequest request, - BreakpointHandler handler, - EventStreamOptions streamOptions, - [NotNullWhen(true)] out IBreakpointLease? lease, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Registers one breakpoint or throws when the capability is unavailable or registration fails. - public IBreakpointLease RegisterBreakpoint( - BreakpointRequest request, - BreakpointHandler handler, - EventStreamOptions streamOptions, - CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs b/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs index 825172c..366bf05 100644 --- a/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs +++ b/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs @@ -5,6 +5,38 @@ namespace CheatEngine.Client.Dispatching; /// Synchronously dispatches managed work to Cheat Engine's captured main thread. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Every operation has a form without a cancellation token and a form with one, like +/// and . The forms without a +/// token are default interface members that pass ; an implementation provides +/// only the forms that take a token. +/// +/// +/// Try is not "never throws". The TryInvoke overloads return only for a +/// pre-admission cancellation or a dispatcher admission/infrastructure failure. They throw +/// when the plugin activation has ended, +/// when the activation is stopping, unless the call comes from +/// a deactivation callback on Cheat Engine's main thread (see ), and +/// for a callback. An exception thrown by the +/// callback is rethrown as the same instance with its original stack trace, never converted into a failure. +/// +/// +/// The cancellation token is observed before dispatch admission only. A result with +/// therefore proves that the callback did not run +/// (). The token never interrupts a callback or a Lua primitive that +/// has begun on Cheat Engine's main thread and never removes an effect that such work produced. +/// +/// +/// Heavy computation on copied managed data may run on a worker, but creating, destroying, or accessing Cheat +/// Engine objects remains subject to the host's thread affinity: does not make +/// driving the Cheat Engine GUI or Lua state safe. +/// +/// public interface ICheatEngineDispatcher { /// Gets whether the caller is already on Cheat Engine's captured main thread. @@ -14,30 +46,160 @@ public bool IsMainThread } /// Runs a callback on the captured main thread. + /// The work to run on Cheat Engine's main thread. + /// The classified failure; the default value on success. + /// when the callback ran and returned. + /// Equivalent to without a token. + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryInvoke(Action callback, out CheatEngineFailure failure) + { + return TryInvoke(callback, out failure, CancellationToken.None); + } + + /// Runs a callback on the captured main thread. + /// The work to run on Cheat Engine's main thread. + /// The classified failure; the default value on success. + /// Observed before dispatch admission only. + /// when the callback ran and returned. /// /// is observed before dispatch admission only. It never attempts to /// interrupt a callback or Lua primitive that has already begun on Cheat Engine's main thread. /// A result represents cancellation or a dispatcher admission/infrastructure failure. - /// Exceptions thrown by are rethrown unchanged. + /// Exceptions thrown by are rethrown unchanged (same instance). Lifecycle faults throw + /// or + /// instead of returning a failure. /// - public bool TryInvoke(Action callback, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryInvoke(Action callback, out CheatEngineFailure failure, CancellationToken cancellationToken); /// Runs a callback on the captured main thread and returns its managed result. + /// The type of the callback's result. + /// The work to run on Cheat Engine's main thread; its result is returned. + /// The callback's result on success; otherwise the default value. + /// The classified failure; the default value on success. + /// when the callback ran and returned. + /// + /// Equivalent to without a + /// token. + /// + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, out CheatEngineFailure failure) + { + return TryInvoke(callback, out result, out failure, CancellationToken.None); + } + + /// Runs a callback on the captured main thread and returns its managed result. + /// The type of the callback's result. + /// The work to run on Cheat Engine's main thread; its result is returned. + /// The callback's result on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before dispatch admission only. + /// when the callback ran and returned. /// /// Cancellation is observed before dispatch admission and never while the callback is running. A /// result represents cancellation or a dispatcher admission/infrastructure failure; - /// exceptions thrown by are rethrown unchanged. + /// exceptions thrown by are rethrown unchanged (same instance). Lifecycle faults throw + /// instead of returning a failure. + /// + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken); + + /// Runs a callback on the captured main thread or throws when dispatch fails. + /// The work to run on Cheat Engine's main thread. + /// Equivalent to without a token. + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or dispatch failed with + /// . + /// + /// + /// Dispatch failed with any other failure kind: the main-thread invocation failed. + /// + public void Invoke(Action callback) + { + Invoke(callback, CancellationToken.None); + } + + /// Runs a callback on the captured main thread or throws when dispatch fails. + /// The work to run on Cheat Engine's main thread. + /// Observed before dispatch admission only. + /// + /// Cancellation is observed before dispatch admission only, never while a callback is running. Callback + /// exceptions are rethrown unchanged. A dispatch failure is thrown through + /// . /// - public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or dispatch failed with + /// . + /// + /// + /// was observed before dispatch admission; the callback did not run. + /// + /// + /// Dispatch failed with any other failure kind: the main-thread invocation failed. + /// + public void Invoke(Action callback, CancellationToken cancellationToken); /// Runs a callback on the captured main thread or throws when dispatch fails. - /// Cancellation is observed before dispatch admission only, never while a callback is running. - public void Invoke(Action callback, CancellationToken cancellationToken = default); + /// The type of the callback's result. + /// The work to run on Cheat Engine's main thread; its result is returned. + /// The callback's result. + /// Equivalent to without a token. + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or dispatch failed with + /// . + /// + /// + /// Dispatch failed with any other failure kind: the main-thread invocation failed. + /// + public T Invoke(Func callback) + { + return Invoke(callback, CancellationToken.None); + } /// Runs a callback on the captured main thread or throws when dispatch fails. - /// Cancellation is observed before dispatch admission only, never while a callback is running. - public T Invoke(Func callback, CancellationToken cancellationToken = default); + /// The type of the callback's result. + /// The work to run on Cheat Engine's main thread; its result is returned. + /// Observed before dispatch admission only. + /// The callback's result. + /// + /// Cancellation is observed before dispatch admission only, never while a callback is running. Callback + /// exceptions are rethrown unchanged. A dispatch failure is thrown through + /// . + /// + /// is . + /// The plugin activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or dispatch failed with + /// . + /// + /// + /// was observed before dispatch admission; the callback did not run. + /// + /// + /// Dispatch failed with any other failure kind: the main-thread invocation failed. + /// + public T Invoke(Func callback, CancellationToken cancellationToken); } diff --git a/libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs b/libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs deleted file mode 100644 index 751fac3..0000000 --- a/libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace CheatEngine.Client.Events; - -/// -/// Configures bounded materialization of copied host callback observations for one active asynchronous reader. -/// -public readonly record struct EventStreamOptions -{ - /// Creates bounded stream options. - /// is not positive. - /// is not a defined value. - public EventStreamOptions( - int capacity, - EventStreamOverflowPolicy overflowPolicy = EventStreamOverflowPolicy.DropOldest) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); - if (!Enum.IsDefined(overflowPolicy)) - { - throw new ArgumentOutOfRangeException(nameof(overflowPolicy)); - } - - Capacity = capacity; - OverflowPolicy = overflowPolicy; - } - - /// Gets the mandatory maximum number of copied observations buffered for the one active reader. - public int Capacity - { - get; - } - - /// Gets the policy applied when is reached. - public EventStreamOverflowPolicy OverflowPolicy - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs b/libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs deleted file mode 100644 index a2c95c9..0000000 --- a/libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CheatEngine.Client.Events; - -/// Specifies the bounded-admission policy for a copied Client event stream. -public enum EventStreamOverflowPolicy -{ - /// Evicts the oldest buffered observation before admitting the newest observation. - DropOldest, - - /// Rejects the newly raised observation while retaining the buffered observations. - DropNewest, - - /// - /// Terminates observation delivery when its bounded buffer cannot admit an observation. This does not select a - /// native callback disposition. - /// - FailSubscription -} diff --git a/libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs b/libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs deleted file mode 100644 index 7bfc5dc..0000000 --- a/libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace CheatEngine.Client.Events; - -/// Owns a bounded, unicast stream of copied callback observations for one Client activation. -/// The copied event snapshot type. -/// -/// The stream has one active reader at a time; a concurrent reader is rejected and observations are never -/// broadcast. Implementations must not wait for a stream consumer on a Cheat Engine or Lua callback thread, -/// although admission may use a bounded synchronization primitive. Cancelling or disposing an enumerator ends -/// that observation read only; it neither cancels nor decides the native callback. Disposing a lease closes -/// admission, detaches its callback, completes , and releases host state. Activation teardown -/// follows the same release contract, so an expired lease cannot admit later observations. -/// -public interface IEventStreamLease : IDisposable -{ - /// - /// Gets the bounded, single-active-reader stream of copied observations. Buffered observations are delivered in - /// FIFO order to that reader; overflow is reported through and does not provide - /// a native callback decision. - /// - public IAsyncEnumerable Events - { - get; - } - - /// - /// Gets the number of observations not delivered because the bounded stream overflowed or terminal processing - /// discarded buffered observations. - /// - public long DroppedEventCount - { - get; - } - - /// Gets whether this activation-scoped subscription has released its host registration. - public bool IsReleased - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs b/libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs deleted file mode 100644 index 8f5173c..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace CheatEngine.Client.Hashing; - -/// Describes a file hash request that is intentionally separate from target-memory hashing. -public readonly record struct FileHashRequest -{ - /// Creates a file hash request. - /// is blank or relative, or the algorithm is undefined. - public FileHashRequest(string filePath, TargetHashAlgorithm algorithm = TargetHashAlgorithm.Sha256) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - if (!Path.IsPathFullyQualified(filePath)) - { - throw new ArgumentException("A file hash path must be absolute.", nameof(filePath)); - } - - if (!Enum.IsDefined(algorithm)) - { - throw new ArgumentOutOfRangeException(nameof(algorithm)); - } - - FilePath = filePath; - Algorithm = algorithm; - } - - /// Gets the absolute file path. The implementation verifies existence before hashing. - public string FilePath - { - get; - } - - /// Gets the requested hash algorithm. - public TargetHashAlgorithm Algorithm - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs b/libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs deleted file mode 100644 index 6341fef..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace CheatEngine.Client.Hashing; - -/// Contains a copied, normalized digest value. -public readonly record struct HashDigest -{ - /// Creates a digest result. - /// is blank or the algorithm is undefined. - public HashDigest(TargetHashAlgorithm algorithm, string value) - { - if (!Enum.IsDefined(algorithm)) - { - throw new ArgumentOutOfRangeException(nameof(algorithm)); - } - - ArgumentException.ThrowIfNullOrWhiteSpace(value); - Algorithm = algorithm; - Value = value; - } - - /// Gets the algorithm that produced . - public TargetHashAlgorithm Algorithm - { - get; - } - - /// Gets the copied textual digest. - public string Value - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs b/libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs deleted file mode 100644 index f72ab52..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Hashing; - -/// Hashes bounded target memory and files through explicitly separate operations. -public interface IHashingClient -{ - /// Tries to hash an exact target-memory range. - public bool TryHashMemory(MemoryHashRequest request, out HashDigest digest, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Hashes an exact target-memory range or throws when unavailable. - public HashDigest HashMemory(MemoryHashRequest request, CancellationToken cancellationToken = default); - - /// Tries to hash an existing file independently from the selected target. - public bool TryHashFile(FileHashRequest request, out HashDigest digest, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Hashes an existing file independently from the selected target. - public HashDigest HashFile(FileHashRequest request, CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs b/libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs deleted file mode 100644 index 1119f64..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Hashing; - -/// Describes a bounded target-memory hash request. -public readonly record struct MemoryHashRequest -{ - /// Creates a target-memory hash request. - /// is not positive or the algorithm is undefined. - public MemoryHashRequest(Address address, int length, TargetHashAlgorithm algorithm = TargetHashAlgorithm.Sha256) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); - if (!Enum.IsDefined(algorithm)) - { - throw new ArgumentOutOfRangeException(nameof(algorithm)); - } - - Address = address; - Length = length; - Algorithm = algorithm; - } - - /// Gets the first target address included in the hash. - public Address Address - { - get; - } - - /// Gets the exact positive number of target bytes included in the hash. - public int Length - { - get; - } - - /// Gets the requested hash algorithm. - public TargetHashAlgorithm Algorithm - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs b/libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs deleted file mode 100644 index eaf5b3d..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CheatEngine.Client.Hashing; - -/// Identifies a hash algorithm supported by the Client hashing contract. -public enum TargetHashAlgorithm -{ - /// MD5. - Md5, - - /// SHA-1. - Sha1, - - /// SHA-256. - Sha256 -} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs deleted file mode 100644 index ee89eb5..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace CheatEngine.Client.Hotkeys; - -/// Contains a copied target hotkey activation. -public readonly record struct HotkeyEvent -{ - /// Creates a copied hotkey activation. - /// is blank. - public HotkeyEvent(string name, HotkeyGesture gesture, DateTimeOffset occurredAt) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - Name = name; - Gesture = gesture; - OccurredAt = occurredAt; - } - - /// Gets the Client registration name. - public string Name - { - get; - } - - /// Gets the copied gesture that was activated. - public HotkeyGesture Gesture - { - get; - } - - /// Gets the copied activation timestamp. - public DateTimeOffset OccurredAt - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs deleted file mode 100644 index 750f6ac..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace CheatEngine.Client.Hotkeys; - -/// Describes a platform-neutral virtual-key gesture. -public readonly record struct HotkeyGesture -{ - /// Creates a hotkey gesture. - /// - /// is outside the byte-sized Windows - /// virtual-key range. - /// - public HotkeyGesture(int virtualKey, HotkeyModifiers modifiers = HotkeyModifiers.None) - { - if (virtualKey is < byte.MinValue or > byte.MaxValue) - { - throw new ArgumentOutOfRangeException(nameof(virtualKey)); - } - - if ((modifiers & ~(HotkeyModifiers.Alt | HotkeyModifiers.Control | HotkeyModifiers.Shift | - HotkeyModifiers.Windows)) != 0) - { - throw new ArgumentOutOfRangeException(nameof(modifiers)); - } - - VirtualKey = virtualKey; - Modifiers = modifiers; - } - - /// Gets the platform virtual-key code. - public int VirtualKey - { - get; - } - - /// Gets the requested modifier combination. - public HotkeyModifiers Modifiers - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs deleted file mode 100644 index 411552f..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CheatEngine.Client.Hotkeys; - -/// Handles a copied hotkey event synchronously without blocking the host callback thread. -public delegate void HotkeyHandler(HotkeyEvent hotkeyEvent); diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs deleted file mode 100644 index c6e2197..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CheatEngine.Client.Hotkeys; - -/// Specifies modifier keys that participate in a registered target hotkey. -[Flags] -public enum HotkeyModifiers -{ - /// No modifier key. - None = 0, - - /// The Alt modifier. - Alt = 1, - - /// The Control modifier. - Control = 2, - - /// The Shift modifier. - Shift = 4, - - /// The Windows modifier. - Windows = 8 -} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs deleted file mode 100644 index 300a709..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace CheatEngine.Client.Hotkeys; - -/// Describes one Client-owned hotkey registration. -public readonly record struct HotkeyRegistration -{ - /// Creates a hotkey registration. - /// is blank. - public HotkeyRegistration(string name, HotkeyGesture gesture) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - Name = name; - Gesture = gesture; - } - - /// Gets the Client-unique diagnostic name. - public string Name - { - get; - } - - /// Gets the registered hotkey gesture. - public HotkeyGesture Gesture - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs deleted file mode 100644 index 01962d7..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Hotkeys; - -/// Registers Client-owned hotkeys for the current activation. -public interface IHotkeyClient -{ - /// Tries to register one hotkey and a bounded copied event stream. - public bool TryRegister( - HotkeyRegistration registration, - HotkeyHandler handler, - EventStreamOptions streamOptions, - [NotNullWhen(true)] out IHotkeyLease? lease, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Registers one hotkey or throws when the capability is unavailable or registration fails. - public IHotkeyLease Register( - HotkeyRegistration registration, - HotkeyHandler handler, - EventStreamOptions streamOptions, - CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs deleted file mode 100644 index 29b4268..0000000 --- a/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Hotkeys; - -/// Owns one Client hotkey registration and its bounded copied event stream. -public interface IHotkeyLease : IEventStreamLease -{ - /// Gets the registration owned by this lease. - public HotkeyRegistration Registration - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs b/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs index 4b05b8e..f1ea850 100644 --- a/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs +++ b/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs @@ -1,32 +1,96 @@ +using System.Diagnostics.CodeAnalysis; + using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; +using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; namespace CheatEngine.Client; /// A scoped, high-level client for the active Cheat Engine plugin lifecycle. /// -/// The client never exposes Lua states, CE object handles, or SDK ownership wrappers. Its members are synchronous -/// because -/// an attached Cheat Engine Lua runtime cannot safely be retained across an await boundary. +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Handle-free surface. No member of this client, and no service, value or lease it returns, exposes a Lua +/// state, a Lua reference, a CE object handle or an SDK ownership wrapper. That guarantee covers the Client +/// surface only. A plugin that derives from CheatEngineClientPlugin (CheatEngine.Client.Hosting) also +/// inherits CheatEngine.SDK's protected static CheatEnginePlugin.Context, the raw SDK plugin context +/// of the current enable, which CheatEngineClientPlugin documents as a raw SDK escape hatch: it is outside +/// every guarantee of this client (activation epochs, main-thread dispatch, failure classification, resource +/// ownership and release, redaction). Code that uses it, or any other CheatEngine.SDK API directly, follows the +/// CheatEngine.SDK contract instead. +/// +/// +/// Try and throwing forms. An operation that can fail for an expected reason has a TryX form, +/// which returns with a classified , and a throwing +/// X form, which returns the same value or throws that same failure through +/// . Use the Try form when the failure is an +/// expected condition. Try does not mean "never throws": both forms throw an +/// for a null argument, a request, an undefined +/// enum value or an out-of-range number (a programming error, checked before the activation and before any +/// Cheat Engine call), then throw for an expired or stopping activation, and both rethrow, unchanged, an +/// exception thrown by application code that the client calls (a dispatcher callback, a memory codec, a Lua +/// operation). No CheatEngine.SDK exception is thrown by a Try form. Some operations also have an +/// XDetailed form that returns an outcome (IsSuccess, Failure and the operation's facts) +/// instead of throwing an expected failure; it throws exactly what the Try form throws. +/// +/// +/// Exceptions. The exception type depends only on : +/// throws , an +/// ; throws +/// ; throws +/// ; every other kind throws +/// . Each exception keeps the complete failure, and none has a public +/// constructor: throws one and +/// creates one. Classify a failure by +/// its and , never by +/// or exception text, which are not contractual and may contain user +/// data. Releasing a lease never throws: returns an outcome. +/// +/// +/// Cancellation. A is observed before Cheat Engine work is dispatched and +/// between Client-managed steps. It never interrupts a Cheat Engine call that has started and never removes an +/// effect that such a call produced: says whether the work started. +/// is cancelled when the plugin begins to disable; the client then admits no new work, +/// except from a deactivation callback. +/// +/// +/// Deactivation callbacks. When the plugin disables, CheatEngineClientPlugin.OnClientDisabling +/// (CheatEngine.Client.Hosting) and each module's +/// run on Cheat Engine's main thread after was cancelled and before the activation +/// releases what it owns. A call they make on that thread still works on existing state: , +/// , the reads of , the Address List records of +/// , , the current process of , +/// , the operations of an existing value-scan session, and the release of any lease. +/// Every other call still throws there: a call that creates a +/// lease (a symbol registration, a value-scan session, an allocation, an Auto Assembler patch, a Lua module), +/// a process attach, Lua and unsafe Lua execution, instructions, Auto Assembler scripts and table files. The +/// context a memory codec receives refuses too, so a codec read or write fails when the codec uses it; a +/// current-process read fails when it finds a changed target selection, which cannot advance while the +/// activation stops; and a thread that a callback starts is refused like any other caller. +/// +/// +/// Threading. Every member is synchronous, because an attached Cheat Engine Lua runtime cannot safely be +/// retained across an await boundary. Members can be called from any thread while the activation is +/// active: Cheat Engine work always runs on Cheat Engine's main thread through , and the +/// calling thread waits for it. Do not make a worker wait for the main thread if that worker can call back into +/// the client. Returned values are copies that stay valid after the activation ends; leases and sessions belong +/// to the activation () and, when bound to the selected target, to that target. +/// /// public interface ICheatEngineClient { - /// Gets the SDK lifecycle epoch captured for this scoped client. + /// Gets the activation epoch captured for this scoped client. public long Epoch { get; @@ -62,14 +126,16 @@ public IMemoryClient Memory get; } - /// Gets AOB and value-scan operations. + /// Gets AOB scan operations. public IPatternScanner Patterns { get; } - /// Gets value-scan operations. - public IValueScanner Scans + /// Gets value-scan operations over Cheat Engine's scanner. + /// Experimental (CECLIENT5001): see the Abstractions README. + [Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] + public IValueScanner ValueScans { get; } @@ -92,57 +158,22 @@ public ILuaClient Lua get; } - /// Gets owned target-memory allocation operations. + /// Gets target-memory allocations, each owned by a lease bound to the selected target. + /// Experimental (CECLIENT5002): see the Abstractions README. + [Experimental(ClientExperimentalDiagnostics.Allocations, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public IAllocationClient Allocations { get; } - /// Gets copied assembly, disassembly, comment, and Auto Assembler patch operations. + /// Gets copied single-instruction assembly, disassembly and length operations. + /// + /// Experimental (CECLIENT5003). Auto Assembler patches are not part of it: they are applied through + /// IAutoAssemblerClient, which only the EnableAutoAssemblerPatches() opt-in registers. + /// + [Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public IAssemblyClient Assembly { get; } - - /// Gets bounded DLL injection and remote-call operations. - public IRemoteExecutionClient RemoteExecution - { - get; - } - - /// Gets breakpoint and copied debugger-event operations. - public IDebuggerClient Debugger - { - get; - } - - /// Gets activation-scoped hotkey operations. - public IHotkeyClient Hotkeys - { - get; - } - - /// Gets activation-scoped timer operations. - public ITimerClient Timers - { - get; - } - - /// Gets validated target-speed operations. - public ISpeedClient Speed - { - get; - } - - /// Gets explicitly separated target-memory and file hashing operations. - public IHashingClient Hashing - { - get; - } - - /// Gets observational and explicitly initialized DBVM operations. - public IDbvmClient Dbvm - { - get; - } } diff --git a/libs/CheatEngine.Client.Abstractions/ICheatEngineLease.cs b/libs/CheatEngine.Client.Abstractions/ICheatEngineLease.cs new file mode 100644 index 0000000..a1d7ac2 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/ICheatEngineLease.cs @@ -0,0 +1,96 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client; + +/// Owns one Cheat Engine resource that the Client created for the current activation. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// releases the resource on Cheat Engine's main thread and returns what happened as a +/// ; it does not throw for a cleanup that failed, was refused, or could not +/// begin. performs the same release, never throws, and discards the +/// outcome, which still reports. +/// +/// +/// Releasing is idempotent. Once an attempt ends the lease (), a later +/// or makes no Cheat Engine call and returns the +/// outcome that ended the lease (), unchanged, so a refused or unconfirmed +/// release is never reported as complete by a second call. A retryable outcome +/// () keeps the lease active: a later release tries again, and the +/// activation cleanup tries again before the plugin is disabled. +/// +/// +/// The activation owns every lease it created. A lease that was never released is released when the plugin is +/// disabled. A target-bound lease (an allocation, a value-scan session, an Auto Assembler patch) also ends when +/// the Client observes that Cheat Engine selected another process, but that release frees nothing: it reaches +/// CheatEngine.SDK after Cheat Engine already targets the new process, so CheatEngine.SDK refuses it before any +/// Cheat Engine call ( or another refusal, which requires +/// manual recovery) and no later release can free the resource: an allocation or a patch stays in the previous +/// process, and the scanner and found list of a session stay in Cheat Engine. Release a target-bound lease +/// before selecting another process. A release that is still incomplete when the plugin is disabled (a +/// retryable outcome that failed again, a refusal, an unconfirmed or partial cleanup) is reported in the +/// aggregated deactivation failure, not thrown to the code that released the lease. +/// +/// +public interface ICheatEngineLease : IDisposable +{ + /// + /// Gets whether the lease has ended: an attempt returned an outcome that is not retryable, so no later attempt + /// will be made. + /// + /// + /// An ended lease is not necessarily clean: read to know whether the resource was + /// released () or may remain + /// (). + /// + public bool IsReleased + { + get; + } + + /// + /// Gets the outcome of the last release attempt, or when the lease has not been released + /// yet. + /// + /// + /// Once the lease has ended, this value is the outcome of the attempt that ended it; a repeated release returns it + /// and does not replace it. + /// + public LeaseReleaseOutcome? LastReleaseOutcome + { + get; + } + + /// + /// Gets whether what the lease owns may remain in Cheat Engine or in the target and no later release of this lease + /// can remove it. + /// + /// + /// It becomes when a release attempt leaves the resource behind: the outcome that ended + /// the lease requires manual recovery (), or the attempt + /// consumed the owner's only means of release with an outcome this Client version does not recognize, which keeps + /// the lease active. It is before any release, after a complete release, and after any + /// other retryable outcome. It never anticipates a release: an Auto Assembler patch lease whose CanDisable + /// is before any release already signals that its release cannot run the patch's + /// [DISABLE] section. + /// + public bool RequiresManualRecovery + { + get; + } + + /// Releases the resource on Cheat Engine's main thread and returns what happened. + /// + /// The outcome of this attempt; when an earlier attempt already ended the lease, the outcome of that attempt + /// (), without any Cheat Engine call. + /// + /// + /// The method can be called from any thread: the release itself runs on Cheat Engine's main thread. When the + /// work cannot be dispatched (for example while the plugin is being disabled, from a thread other than the main + /// thread), the outcome is and the lease stays active. + /// + public LeaseReleaseOutcome Release(); +} diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/AddressResolutionMode.cs b/libs/CheatEngine.Client.Abstractions/Inspection/AddressResolutionMode.cs new file mode 100644 index 0000000..ef764a2 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Inspection/AddressResolutionMode.cs @@ -0,0 +1,16 @@ +namespace CheatEngine.Client.Inspection; + +/// Selects how Cheat Engine resolves one address expression in the target process's symbol table. +/// +/// The mode is forwarded, through CheatEngine.SDK and without reinterpretation, as the optional shallow argument +/// of Cheat Engine's getAddressSafe; its effect on the lookup is Cheat Engine's. Resolution always queries the +/// target process; resolving in Cheat Engine's own process is not offered. +/// +public enum AddressResolutionMode +{ + /// Cheat Engine's ordinary resolution: shallow is . + Default = 0, + + /// Cheat Engine's shallow resolution: shallow is . + Shallow = 1 +} diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs b/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs index 2e64d8b..94e5297 100644 --- a/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs @@ -8,21 +8,93 @@ namespace CheatEngine.Client.Inspection; /// Reads copied modules, sections, symbols, and memory regions from the selected Cheat Engine target. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// A or out-of-range argument is a programming error, thrown before the activation +/// check and before any Cheat Engine call: an for a +/// , which allows no item, or a process +/// identifier that is not positive, and an for a +/// , or , which name +/// nothing. +/// +/// +/// After its arguments, every member checks the activation: an ended activation throws +/// and a stopping one +/// , except that a deactivation callback can still call every +/// member but and on Cheat Engine's main +/// thread (see ). A Try member returns every other failure; the +/// throwing member with the same inputs throws it through +/// . +/// +/// public interface IInspectionClient { - /// Tries to copy the target modules, optionally for an explicit process identifier. + /// Tries to copy the target modules, for the selected target or an explicit process identifier. + /// The bound on the number of copied modules. + /// + /// The process whose modules are copied, or for Cheat Engine's selected target. + /// + /// The copied modules when the method returns . + /// The failure when the method returns . + /// Observed before the work is dispatched. + /// when the modules were copied. + /// + /// is the request, which allows no item, + /// or is not positive. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetModules( InspectionCollectionRequest request, + TargetProcessId? processId, out ImmutableArray modules, out CheatEngineFailure failure, - TargetProcessId? processId = null, CancellationToken cancellationToken = default); /// Copies the target modules or throws when inspection fails. + /// The bound on the number of copied modules. + /// + /// The process whose modules are copied, or for Cheat Engine's selected target. + /// + /// Observed before the work is dispatched. + /// The copied modules. + /// + /// is the request, which allows no item, + /// or is not positive. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or inspection failed with + /// . + /// + /// + /// Inspection observed the cancellation of . + /// + /// Inspection failed with any other failure kind. public ImmutableArray GetModules(InspectionCollectionRequest request, TargetProcessId? processId = null, CancellationToken cancellationToken = default); /// Copies the sections belonging to one module. + /// The name of the module whose sections are copied. + /// The bound on the number of copied sections. + /// The copied sections on success; otherwise an empty array. + /// The classified failure; the default value on success. + /// Observed before the work is dispatched. + /// when the sections were copied. + /// + /// is the name, or is the + /// request (an ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetModuleSections( ModuleName moduleName, InspectionCollectionRequest request, @@ -31,10 +103,39 @@ public bool TryGetModuleSections( CancellationToken cancellationToken = default); /// Copies one module's sections or throws when inspection fails. + /// The name of the module whose sections are copied. + /// The bound on the number of copied sections. + /// Observed before the work is dispatched. + /// The copied sections. + /// + /// is the name, or is the + /// request (an ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or inspection failed with + /// . + /// + /// + /// Inspection observed the cancellation of . + /// + /// Inspection failed with any other failure kind. public ImmutableArray GetModuleSections(ModuleName moduleName, InspectionCollectionRequest request, CancellationToken cancellationToken = default); /// Copies the target memory-region map. + /// The bound on the number of copied regions. + /// The copied regions on success; otherwise an empty array. + /// The classified failure; the default value on success. + /// Observed before the work is dispatched. + /// when the regions were copied. + /// + /// is the request, which allows no item. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetMemoryRegions( InspectionCollectionRequest request, out ImmutableArray regions, @@ -42,10 +143,34 @@ public bool TryGetMemoryRegions( CancellationToken cancellationToken = default); /// Copies the target memory-region map or throws when inspection fails. + /// The bound on the number of copied regions. + /// Observed before the work is dispatched. + /// The copied regions. + /// + /// is the request, which allows no item. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or inspection failed with + /// . + /// + /// + /// Inspection observed the cancellation of . + /// + /// Inspection failed with any other failure kind. public ImmutableArray GetMemoryRegions(InspectionCollectionRequest request, CancellationToken cancellationToken = default); /// Copies the memory-region metadata containing one target address. + /// The target address whose region is copied. + /// The copied region on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the work is dispatched. + /// when the region was copied. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetMemoryRegion( Address address, out MemoryRegionInfo region, @@ -53,9 +178,33 @@ public bool TryGetMemoryRegion( CancellationToken cancellationToken = default); /// Gets one region or throws when inspection fails. + /// The target address whose region is copied. + /// Observed before the work is dispatched. + /// The copied region. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or inspection failed with + /// . + /// + /// + /// Inspection observed the cancellation of . + /// + /// Inspection failed with any other failure kind. public MemoryRegionInfo GetMemoryRegion(Address address, CancellationToken cancellationToken = default); /// Copies metadata for one Cheat Engine symbol expression. + /// The symbol expression to look up. + /// The copied symbol metadata on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the work is dispatched. + /// when the symbol was found and copied. + /// + /// is the expression. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetSymbol( SymbolExpression expression, out SymbolInfo symbol, @@ -63,9 +212,33 @@ public bool TryGetSymbol( CancellationToken cancellationToken = default); /// Gets symbol information or throws when inspection fails. + /// The symbol expression to look up. + /// Observed before the work is dispatched. + /// The copied symbol metadata. + /// + /// is the expression. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or inspection failed with + /// . + /// + /// + /// Inspection observed the cancellation of . + /// + /// Inspection failed with any other failure kind. public SymbolInfo GetSymbol(SymbolExpression expression, CancellationToken cancellationToken = default); /// Resolves the best Cheat Engine symbol name for one target address. + /// The target address to name. + /// The resolved name on success; otherwise . + /// The classified failure; the default value on success. + /// Observed before the work is dispatched. + /// when a name was resolved. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryResolveName( Address address, [NotNullWhen(true)] out string? name, @@ -73,9 +246,37 @@ public bool TryResolveName( CancellationToken cancellationToken = default); /// Resolves a symbol name or throws when no name can be resolved. + /// The target address to name. + /// Observed before the work is dispatched. + /// The resolved name. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or resolution failed with + /// . + /// + /// + /// Resolution observed the cancellation of . + /// + /// Resolution failed with any other failure kind. public string ResolveName(Address address, CancellationToken cancellationToken = default); /// Registers a Client-owned Cheat Engine symbol and returns the lease that removes it. + /// The symbol name, its address and whether a saved table omits it. + /// The lease that owns the symbol on success; release it when done. + /// The classified failure; the default value on success. + /// Observed before the registration is dispatched. + /// when the symbol was registered and its lease published. + /// + /// A name that already resolves, or that this activation already registered, is refused before Cheat Engine + /// registers anything. + /// + /// + /// is the registration, which names no symbol. + /// + /// The activation has ended. + /// + /// The activation is stopping: no new lease is created while it stops. + /// public bool TryRegisterSymbol( SymbolRegistration registration, [NotNullWhen(true)] out ISymbolRegistrationLease? lease, @@ -83,18 +284,71 @@ public bool TryRegisterSymbol( CancellationToken cancellationToken = default); /// Registers a Client-owned symbol or throws when Cheat Engine rejects the registration. + /// The symbol name, its address and whether a saved table omits it. + /// Observed before the registration is dispatched. + /// The lease that owns the symbol; release it when done. + /// + /// is the registration, which names no symbol. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the registration failed with + /// . + /// + /// + /// The registration observed the cancellation of . + /// + /// The registration failed with any other failure kind. public ISymbolRegistrationLease RegisterSymbol(SymbolRegistration registration, CancellationToken cancellationToken = default); - /// Resolves one Cheat Engine address expression with the documented SDK options. + /// Resolves one Cheat Engine address expression in the target process's symbol table. + /// The address expression, for example game.exe+10 or a registered symbol name. + /// How Cheat Engine resolves the expression. + /// The resolved address when the method returns . + /// + /// The failure when the method returns ; + /// when the expression does not resolve. + /// + /// Observed before the work is dispatched. + /// when the expression resolved. + /// + /// is the expression. + /// + /// is not a defined value. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryResolveAddress( SymbolExpression expression, - AddressResolutionOptions options, + AddressResolutionMode mode, out Address address, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Resolves one address or throws when resolution fails. - public Address ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + /// + /// The address expression, for example game.exe+10 or a registered symbol name. + /// + /// How Cheat Engine resolves the expression. + /// Observed before the work is dispatched. + /// The resolved address. + /// + /// is the expression. + /// + /// is not a defined value. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or resolution failed with + /// . + /// + /// + /// Resolution observed the cancellation of . + /// + /// + /// Resolution failed with any other failure kind, included. + /// + public Address ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs b/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs index 24c2d14..d144560 100644 --- a/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs +++ b/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs @@ -2,12 +2,37 @@ namespace CheatEngine.Client.Inspection; -/// Owns one Client-created Cheat Engine symbol registration for the current activation epoch. +/// Owns one Client-created Cheat Engine symbol registration for the current activation. /// -/// Disposing the lease removes only the symbol registered through this lease. The activation owner also disposes a -/// forgotten lease before the SDK detaches the Lua runtime. +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// The name was registered through CheatEngine.SDK's symbol ownership coordinator, and the release delegates to +/// it. The name is unregistered only while it still resolves to and no newer registration +/// of the same name through that coordinator superseded the lease: a name that a third party replaced or removed +/// is left in place. The check and the unregistration are best effort, not atomic: Cheat Engine has no +/// registration token, so a third party can replace the name in between, and a third-party registration of the +/// same name at the same address is indistinguishable and is removed. +/// +/// +/// reports what happened, and never +/// throws: ; +/// or +/// (the name no longer resolves to +/// ; nothing was unregistered); +/// (a newer registration of the name +/// through CheatEngine.SDK owns it now; nothing was unregistered); +/// (the Lua runtime of the +/// registration is gone, so no call was made and the name may remain); +/// (the unregistration began and +/// failed); and the retryable (no +/// call began or the ownership could not be verified: the lease stays active and the activation cleanup tries +/// again before the plugin is disabled). +/// /// -public interface ISymbolRegistrationLease : IDisposable +public interface ISymbolRegistrationLease : ICheatEngineLease { /// Gets the exact symbol name registered in Cheat Engine. public string Name @@ -20,10 +45,4 @@ public Address Address { get; } - - /// Gets a value indicating whether this lease has already released its registration. - public bool IsReleased - { - get; - } } diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs b/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs index 1aced3c..91cc6b7 100644 --- a/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs @@ -4,6 +4,8 @@ namespace CheatEngine.Client.Inspection; public readonly record struct InspectionCollectionRequest { /// Creates a bounded inspection request. + /// The positive maximum number of records to copy. + /// is zero or negative. public InspectionCollectionRequest(int maximumItems) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs b/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs index e4680dc..60d9d3f 100644 --- a/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs +++ b/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs @@ -10,6 +10,11 @@ namespace CheatEngine.Client.Inspection; public readonly record struct SymbolRegistration { /// Creates a custom symbol definition. + /// The session-wide symbol name; use a plugin-specific prefix. + /// The target address the name designates. + /// Whether Cheat Engine omits the registration when it saves a table. + /// is . + /// is empty or white space. public SymbolRegistration(string name, Address address, bool doNotSave = true) { ArgumentException.ThrowIfNullOrWhiteSpace(name); diff --git a/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs index add6b76..d1e3fcf 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs @@ -6,9 +6,17 @@ namespace CheatEngine.Client.Lua; /// /// /// The annotated type is a non-static partial class. names the static partial -/// SDK binding type that owns the generated RegisterLuaFunctions and UnregisterLuaFunctions -/// methods. The Client generator reads this declaration at compile time; it never discovers modules through -/// reflection at run time. +/// SDK binding type whose [LuaFunction] methods the CheatEngine.SDK generator turns into the +/// ownership-aware TryRegisterLuaFunctions method. The Client generator reads this declaration at compile +/// time; it never discovers modules through reflection at run time. +/// +/// +/// The generated module implements . It registers through TryRegisterLuaFunctions +/// with the RejectExisting collision policy and holds the CheatEngine.SDK registration lease. It never calls +/// the legacy RegisterLuaFunctions/UnregisterLuaFunctions pair: at release the lease writes each +/// exported global only while the global still holds the value the module installed, leaves a third-party +/// replacement untouched, and returns what it observed as a +/// . /// /// /// The optional name is the stable identity reserved by for one activation. When it @@ -16,10 +24,17 @@ namespace CheatEngine.Client.Lua; /// from the [LuaFunction] declarations on . /// /// +/// +/// The static partial SDK binding type whose [LuaFunction] methods the module exports. +/// +/// +/// The stable module identity, or to use the module type's simple name. +/// +/// is . [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class CheatEngineLuaModuleAttribute(Type bindingsType, string? name = null) : Attribute { - /// Gets the static SDK binding type that owns the generated Lua registration pair. + /// Gets the static SDK binding type that owns the SDK-generated Lua registration method. public Type BindingsType { get; diff --git a/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs index 0d88d85..a70fd42 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs @@ -26,6 +26,7 @@ public CheatEngineLuaOperationAttribute() /// Initializes operation generation with one explicit static Client result mapper. /// The type that implements the required static mapper contract. + /// is . public CheatEngineLuaOperationAttribute(Type mapperType) { MapperType = mapperType ?? throw new ArgumentNullException(nameof(mapperType)); diff --git a/libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs b/libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs deleted file mode 100644 index 1b5521c..0000000 --- a/libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CheatEngine.Client.Lua; - -/// -/// Describes an explicit Lua module before the Client mutates Cheat Engine's global Lua environment. -/// -/// -/// The descriptor is copied metadata only. It intentionally contains neither a Lua state nor any SDK ownership, -/// reference, callback, or native handle. uses it to reserve the module and all export -/// names atomically for an activation before invoking . -/// -public interface IDescribedLuaModule : ILuaModule -{ - /// Gets this module's stable identity and exported Lua global names. - public LuaModuleDescriptor Descriptor - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs index f3372e3..96cd1b8 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs @@ -5,13 +5,55 @@ namespace CheatEngine.Client.Lua; /// Executes typed Lua operations without exposing a Lua state or CE object handle. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// One generic pair executes every operation: the operation type is a type parameter, so a readonly value +/// operation is passed by reference and called without boxing. For each [CheatEngineLuaOperation] the +/// generator also emits Execute and TryExecute extension methods on +/// whose types are inferred from the operation, so a call reads client.Execute(operation). +/// +/// +/// After its arguments, every member checks the activation: an ended activation throws +/// and a stopping one +/// , from a deactivation callback too (see +/// ). A Try member returns every other failure; the +/// throwing member with the same inputs throws it through +/// . An exception thrown by an operation propagates +/// unchanged, as the same instance. +/// +/// public interface ILuaClient { /// Tries to register one explicitly supplied application Lua module on Cheat Engine's main thread. + /// The module to register, usually one the Lua generator emitted. + /// The lease that owns the module's registration on success; release it when done. + /// + /// The classified failure, including the failure of a registration the module itself reported; the default + /// value on success. + /// + /// + /// Observed before the registration is dispatched to Cheat Engine's main thread. + /// + /// when the module registered its exports and its lease was published. /// /// The Client neither scans assemblies nor discovers exports by reflection. The module owns its generated SDK - /// calls; the returned lease gives the Client deterministic, activation-scoped cleanup ownership. + /// calls; the returned lease gives the Client deterministic, activation-scoped cleanup ownership. A module + /// identity or export name that another module of this activation reserved is refused with + /// , like a symbol name the activation already owns, + /// and a module instance that is already registered with ; + /// both report before any Lua call. /// + /// is . + /// + /// The of is the + /// descriptor, which names no module. + /// + /// The activation has ended. + /// The activation is stopping. public bool TryRegisterModule( ILuaModule luaModule, [NotNullWhen(true)] out ILuaModuleLease? lease, @@ -19,57 +61,62 @@ public bool TryRegisterModule( CancellationToken cancellationToken = default); /// Registers one explicitly supplied application Lua module or throws when registration fails. + /// The module to register, usually one the Lua generator emitted. + /// + /// Observed before the registration is dispatched to Cheat Engine's main thread. + /// + /// The lease that owns the module's registration; release it when done. + /// is . + /// + /// The of is the + /// descriptor, which names no module. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the registration failed with + /// . + /// + /// + /// The registration observed the cancellation of . + /// + /// The registration failed with any other failure kind. public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default); -#pragma warning disable RS0026, RS0027 // The constrained overloads preserve the shipped overloads and require a token. - /// Tries to execute one typed operation on Cheat Engine's main thread. - public bool TryExecute(ILuaOperation operation, [MaybeNullWhen(false)] out TResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Executes one typed operation or throws when it fails. - public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default); - - /// - /// Tries to execute a value-type operation without forcing callers to convert it to an interface explicitly. - /// - /// The readonly value operation type. + /// The operation type; a readonly value operation is never boxed. /// The copied result type. /// The operation to execute. /// The copied result on success. /// The mapped Client failure on failure. /// Cancellation observed before dispatch admission. /// when the operation completed successfully. - /// - /// This default implementation preserves source and binary compatibility for third-party implementations of - /// . Core supplies a constrained implementation that avoids boxing the generated - /// readonly record struct operation on its measured path. - /// + /// An exception the operation throws propagates unchanged, as the same instance. + /// is . + /// The activation has ended. + /// The activation is stopping. public bool TryExecute( - TOperation operation, + in TOperation operation, [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, - CancellationToken cancellationToken) - where TOperation : struct, ILuaOperation - { - return TryExecute(operation, out result, out failure, cancellationToken); - } + CancellationToken cancellationToken = default) + where TOperation : ILuaOperation; - /// Executes a value-type operation or throws when it fails. - /// The readonly value operation type. + /// Executes one typed operation on Cheat Engine's main thread or throws when it fails. + /// The operation type; a readonly value operation is never boxed. /// The copied result type. /// The operation to execute. /// Cancellation observed before dispatch admission. /// The copied result. - /// - /// This default implementation preserves compatibility for existing third-party implementations. Core replaces - /// it with constrained dispatch for generated value operations. - /// - public TResult Execute(TOperation operation, CancellationToken cancellationToken) - where TOperation : struct, ILuaOperation - { - return Execute(operation, cancellationToken); - } - -#pragma warning restore RS0026, RS0027 + /// An exception the operation throws propagates unchanged, as the same instance. + /// is . + /// The activation has ended. + /// + /// The activation is stopping, or the operation failed with . + /// + /// + /// The operation observed the cancellation of . + /// + /// The operation failed with any other failure kind. + public TResult Execute(in TOperation operation, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation; } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs index c960cb1..aab5317 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs @@ -4,13 +4,19 @@ namespace CheatEngine.Client.Lua; /// Describes the short-lived, handle-free scope in which a typed Lua operation executes. /// /// -/// A context is valid only while the typed operation invocation that received it is running. It deliberately exposes -/// neither LuaState, LuaRef, CE objects nor raw Lua stack access. -/// Operations should use their SDK-generated [LuaGlobal] bindings while this context is active. +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// A context is valid only while the typed operation invocation that received it is running. It deliberately +/// exposes neither LuaState, LuaRef, CE objects nor raw Lua stack access. Operations should use +/// their SDK-generated [LuaGlobal] bindings while this context is active. +/// /// public interface ILuaExecutionContext { - /// Gets the plugin activation epoch captured for this operation. + /// Gets the activation epoch captured for this operation. public long Epoch { get; diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs index ca04619..2137d5c 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs @@ -1,30 +1,86 @@ namespace CheatEngine.Client.Lua; -/// Explicitly registers one application-owned set of Lua exports for the current Client activation. +/// Registers and releases one application-owned set of Lua globals for the current Client activation. /// -/// The Client invokes both methods synchronously on Cheat Engine's main thread and owns the resulting registration -/// lease. An implementation can call its SDK-generated RegisterLuaFunctions and -/// UnregisterLuaFunctions methods internally, including acquisition of the SDK state required by those -/// generated methods. No SDK Lua state, reference, or raw stack access crosses this Client contract. +/// +/// Implementable. Applications implement this interface and the Client calls it. Its members are frozen +/// for the 1.x line. +/// +/// +/// The Client calls and synchronously on Cheat Engine's main +/// thread, through and the returned , and +/// owns the resulting lease. Before runs, the Client reserves 's +/// module name and every export name for the activation, so two modules of one activation never claim the same +/// Lua global. No SDK Lua state, reference, or raw stack access crosses this Client contract. +/// +/// +/// A module generates all three members. It registers through the +/// SDK-generated TryRegisterLuaFunctions of its bindings type with the RejectExisting collision +/// policy, holds the CheatEngine.SDK registration lease, and releases it ownership-aware: a global is written only +/// while it still holds the value the module installed. It never calls the legacy SDK +/// RegisterLuaFunctions/UnregisterLuaFunctions pair, which writes unconditionally. A manual +/// implementation declares its exports truthfully in and reports its release with the +/// factories. +/// /// public interface ILuaModule { + /// Gets this module's stable identity and the Lua global names it exports. + /// + /// The descriptor is copied metadata only: no Lua state, SDK ownership, reference, callback, or native handle. + /// + public LuaModuleDescriptor Descriptor + { + get; + } + /// Registers this module's exports for the current Cheat Engine activation. /// - /// Throw when the generated SDK registration reports a non-success status so - /// can return the mapped failure. /// - /// A manual module that implements only this interface remains a compatible advanced escape hatch. Because it - /// does not declare its identity or exports, it cannot participate in the Client's activation-wide global - /// collision guarantee. Implement for ordinary application modules. + /// Throw to refuse the registration; returns the failure. To refuse + /// with a classified failure, throw the exception that raises + /// or creates: a + /// or a + /// is reported with the failure it carries. Any + /// other exception is classified by the Client like a CheatEngine.SDK fault, because a generated module + /// surfaces the SDK faults of its registration from this method. + /// + /// + /// A generated module throws the exception of its failure's kind + /// () when CheatEngine.SDK refuses the Lua admission + /// (ActivationExpired, RuntimeChanged, InvalidState, or + /// IndeterminateHostResult for a status the Client does not recognize, with the host effect + /// NotStarted), when a global is already defined (OperationRejected, + /// NotApplied: nothing was published), or when a protected lookup or publication failed + /// (LuaError; NotApplied when the SDK's rollback removed everything it published, + /// CleanupUnconfirmed otherwise). A registration result outside the documented shape fails closed + /// as IndeterminateHostResult, with CleanupUnconfirmed for a success without a lease. + /// Registering a module that still owns a registration releases it first; when that release may leave one + /// of its globals, nothing is published and the failure is CleanupUnconfirmed. /// /// public void Register(); - /// Unregisters this module's exports for the current Cheat Engine activation. + /// Releases this module's exports for the current Cheat Engine activation and reports what happened. + /// The copied release outcome; never . /// - /// This is called at most once by the Client, either by disposing the returned lease or during activation - /// cleanup. Implementations should make their own cleanup safe if an SDK operation reports a failure. + /// + /// The Client calls it once for each successful , and again only after an outcome that + /// left the registration in place ( or + /// ). Report a release that failed in the outcome rather than by + /// throwing: an exception is recorded as an unconfirmed cleanup and never retried. + /// + /// + /// A generated module writes only globals that still hold the value it installed and never overwrites one a + /// third party replaced. It returns without any Lua call + /// when it owns no registration. When CheatEngine.SDK refuses the Lua admission because the Lua universe that + /// holds the registration is gone (Detached or ExternalStateReset), it consumes the registration + /// without any Lua call and returns ; any other + /// refused admission keeps the registration and returns + /// . Once it has consumed the registration it never + /// returns a retryable kind: a release that CheatEngine.SDK reports outside its documented shape is + /// . + /// /// - public void Unregister(); + public LuaModuleReleaseOutcome Unregister(); } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs index bc448ca..1387fca 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs @@ -1,21 +1,37 @@ +using CheatEngine.Client.Results; + namespace CheatEngine.Client.Lua; /// Owns one explicit Lua module registration in a single Cheat Engine client epoch. /// -/// Disposing the lease unregisters its module synchronously on Cheat Engine's main thread. Forgotten leases are -/// released in LIFO order while the activation cleanup scope still permits SDK dispatch. Disposing a lease more -/// than once has no effect. The lease never exposes a Lua state, Lua reference, or native Lua handle. +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// calls the module's synchronously on +/// Cheat Engine's main thread and maps its to the +/// of every Client lease: a release that could not begin +/// () keeps the lease active, a partial release or a registration +/// of an earlier Lua attachment ends it and requires manual recovery, and an exception thrown by the module is an +/// unconfirmed cleanup that is never retried. never throws. Forgotten leases are +/// released in LIFO order while the activation cleanup scope still permits SDK dispatch, and a release that stays +/// incomplete is reported in the aggregated deactivation failure. The lease never exposes a Lua state, Lua +/// reference, or native Lua handle. +/// /// -public interface ILuaModuleLease : IDisposable +public interface ILuaModuleLease : ICheatEngineLease { - /// Gets the Client activation epoch that owns this registration. - public long Epoch - { - get; - } - - /// Gets whether this lease has already completed its one release attempt. - public bool IsReleased + /// + /// Gets what the module reported for the last release attempt that reached it, or before + /// one did. + /// + /// + /// It keeps the counts and failed globals that CheatEngine.SDK observed, which + /// summarizes as a kind and a host effect. A release that could + /// not be dispatched, or whose module threw, leaves it unchanged. + /// + public LuaModuleReleaseOutcome? LastModuleReleaseOutcome { get; } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs index d13e675..3f4bafd 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs @@ -6,6 +6,10 @@ namespace CheatEngine.Client.Lua; /// A typed, handle-free Lua operation implemented with SDK-generated bindings. /// The copied managed result type. +/// +/// Implementable. Applications implement this interface and the Client calls it. Its members are frozen for +/// the 1.x line. +/// public interface ILuaOperation { /// Executes while the Client holds a valid activation and main-thread boundary. diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs index 6b185c5..7aa2f86 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs @@ -6,9 +6,20 @@ namespace CheatEngine.Client.Lua; /// The SDK binding result type visible only inside generated operation code. /// The copied Client result type exposed by the operation. /// -/// Implementations must not retain their source value when it is backed by a Lua reference, CE object, owned -/// wrapper, native pointer, span, or other activation-bound SDK resource. The generated operation invokes this -/// member directly through static abstract interface dispatch; it never uses reflection. +/// +/// Implementable. Applications implement this interface and the Client calls it. Its members are frozen +/// for the 1.x line. +/// +/// +/// Implementations must not retain their source value when it is backed by a Lua reference, CE object, owned +/// wrapper, native pointer, span, or other activation-bound SDK resource. The generated operation invokes this +/// member directly through static abstract interface dispatch; it never uses reflection. +/// +/// +/// The generated operation calls the mapper after the SDK binding returned, outside the code that classifies +/// the binding's failures: an exception the mapper throws leaves the operation unchanged, and the Client +/// rethrows it as the same instance, like any exception of application-supplied code. +/// /// public interface ILuaResultMapper { diff --git a/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs b/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs index 38b7277..81558e8 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs @@ -3,12 +3,59 @@ namespace CheatEngine.Client.Lua; /// Opt-in execution of trusted arbitrary Lua source. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Dependency injection registers it only when the activation calls EnableUnsafeLuaExecution(); an +/// execution that the activation policy did not enable fails with +/// and +/// . After its arguments, every member checks the activation: an +/// ended activation throws and a stopping one +/// , from a deactivation callback too (see +/// ). +/// +/// public interface IUnsafeLuaClient { /// Tries to execute trusted Lua source through the SDK protected-call boundary. + /// The trusted Lua source and its optional chunk name. + /// + /// The classified failure; the default value on success. A script that failed may have run partially. + /// + /// Observed before the script is dispatched to Cheat Engine's main thread. + /// when the script ran without a Lua error. + /// + /// is the script, which has no source. + /// + /// + /// is a tampered script whose chunk name is empty. + /// + /// The activation has ended. + /// The activation is stopping. public bool TryExecute(LuaScript script, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Executes trusted Lua source or throws when execution fails. + /// The trusted Lua source and its optional chunk name. + /// Observed before the script is dispatched to Cheat Engine's main thread. + /// + /// is the script, which has no source. + /// + /// + /// is a tampered script whose chunk name is empty. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the execution failed with . + /// + /// + /// The execution observed the cancellation of . + /// + /// + /// The execution failed with any other failure kind; a script that failed may have run partially. + /// public void Execute(LuaScript script, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs index 3c7b5ac..8b2e8c3 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs @@ -3,17 +3,19 @@ namespace CheatEngine.Client.Lua; /// Copied metadata that identifies one Lua global exported by a Client module. public readonly record struct LuaExportDescriptor { + private readonly string? _name; + /// Initializes one validated Lua global descriptor. /// The case-sensitive Lua global name. + /// is . + /// is empty or white space. public LuaExportDescriptor(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - Name = name; + _name = name; } /// Gets the case-sensitive Lua global name. - public string Name - { - get; - } + /// for the value. + public string Name => _name ?? string.Empty; } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs index 2d16ef6..b7d7d4e 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs @@ -3,8 +3,11 @@ namespace CheatEngine.Client.Lua; /// Copied metadata that identifies one explicit Lua module and all global names it exports. -public readonly record struct LuaModuleDescriptor +public readonly struct LuaModuleDescriptor { + private readonly ImmutableArray _exports; + private readonly string? _name; + /// Initializes one immutable module descriptor. /// The stable module identity for the Client activation. /// The Lua global names this module will register. @@ -12,11 +15,11 @@ public readonly record struct LuaModuleDescriptor public LuaModuleDescriptor(string name, ImmutableArray exports) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - Name = name; - Exports = exports.IsDefault ? ImmutableArray.Empty : exports; + _name = name; + _exports = exports.IsDefault ? ImmutableArray.Empty : exports; - HashSet? names = Exports.IsEmpty ? null : new HashSet(StringComparer.Ordinal); - foreach (LuaExportDescriptor export in Exports) + HashSet? names = _exports.IsEmpty ? null : new HashSet(StringComparer.Ordinal); + foreach (LuaExportDescriptor export in _exports) { ArgumentException.ThrowIfNullOrWhiteSpace(export.Name); if (!names!.Add(export.Name)) @@ -29,14 +32,11 @@ public LuaModuleDescriptor(string name, ImmutableArray expo } /// Gets the stable module identity for the Client activation. - public string Name - { - get; - } + /// for the value. + public string Name => _name ?? string.Empty; /// Gets immutable copied descriptors of each Lua global this module exports. - public ImmutableArray Exports - { - get; - } + /// Empty for the value, never a default array. + public ImmutableArray Exports => + _exports.IsDefault ? ImmutableArray.Empty : _exports; } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleReleaseOutcome.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleReleaseOutcome.cs new file mode 100644 index 0000000..a21ef81 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleReleaseOutcome.cs @@ -0,0 +1,262 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Lua; + +/// Copied, handle-free result of one release of a Lua module registration. +/// +/// +/// A module generated from releases its globals through its +/// CheatEngine.SDK 2.0.0 registration lease and copies what the SDK observed: the kind of the release, in the Client +/// lease vocabulary, and its counts and failed global names. The SDK writes a global only while it still holds the +/// value the registration installed (primitive identity, no __eq metamethod), so a global that a third party +/// replaced, or that is already nil, is counted in and left untouched. +/// +/// +/// The kinds a generated module reports are , +/// (at least one protected read or write failed; the failed +/// globals are in and are not retried), +/// (the registration belongs to an earlier Lua attachment or +/// state, or CheatEngine.SDK refused the Lua admission with Detached or ExternalStateReset: the +/// registration is consumed, nothing was written, and its globals may remain as functions that raise an error), +/// (the module owned no registration), +/// (CheatEngine.SDK refused the Lua admission for any other +/// reason; the module keeps its registration for a later attempt) and +/// (CheatEngine.SDK consumed the registration but reported a +/// release outside its documented shape; it is never retried). A manual reports its own +/// release with the factories of this type. +/// +/// +/// The outcome is immutable and holds copied names and counts only: never a Lua state, reference, or native handle. +/// +/// +public sealed class LuaModuleReleaseOutcome +{ + private LuaModuleReleaseOutcome(string moduleName, LeaseReleaseKind kind, int removedCount, int restoredCount, + int replacementCount, int remainingCount, ImmutableArray failedExports) + { + ModuleName = moduleName; + Kind = kind; + RemovedCount = removedCount; + RestoredCount = restoredCount; + ReplacementCount = replacementCount; + RemainingCount = remainingCount; + FailedExports = failedExports; + } + + /// Gets the stable module identity (). + public string ModuleName + { + get; + } + + /// Gets what the release did, in the Client lease vocabulary. + public LeaseReleaseKind Kind + { + get; + } + + /// Gets the number of globals that still held the module's value and were set to nil. + public int RemovedCount + { + get; + } + + /// + /// Gets the number of globals whose earlier value was put back. A generated module refuses to replace a defined + /// global, so it always reports 0. + /// + public int RestoredCount + { + get; + } + + /// + /// Gets the number of globals that no longer held the module's value (a third party replaced them, even with a + /// wrapper of the module's function, or they were already nil); nothing was written to them. + /// + public int ReplacementCount + { + get; + } + + /// + /// Gets the number of globals whose cleanup this attempt did not confirm: the failed ones, or every global of a + /// registration that was not released. + /// + public int RemainingCount + { + get; + } + + /// Gets the globals whose protected read or write failed during the release, in registration order. + public ImmutableArray FailedExports + { + get; + } + + /// + /// Gets whether the release ended and nothing the module owned is known to remain: the rule of + /// applied to . + /// + public bool IsComplete => new LeaseReleaseOutcome(Kind, CheatEngineHostEffect.Unknown).IsComplete; + + /// Creates a validated release outcome from every fact of the release. + /// The stable module identity. + /// What the release did. + /// The globals set to nil because they still held the module's value. + /// The globals whose earlier value was put back. + /// The globals that no longer held the module's value and were left untouched. + /// The globals whose cleanup was not confirmed. + /// + /// The globals whose release failed, in registration order; means none. + /// + /// The outcome. + /// + /// or a failed export name is blank, a failed export is listed twice, failed exports + /// are named for a kind other than or are missing for it, or + /// is smaller than the number of failed exports. + /// + /// + /// is not a defined value, or a count is negative. + /// + public static LuaModuleReleaseOutcome Create(string moduleName, LeaseReleaseKind kind, int removedCount, + int restoredCount, int replacementCount, int remainingCount, ImmutableArray failedExports) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind), kind, "The lease release kind must be a defined value."); + } + + ArgumentOutOfRangeException.ThrowIfNegative(removedCount); + ArgumentOutOfRangeException.ThrowIfNegative(restoredCount); + ArgumentOutOfRangeException.ThrowIfNegative(replacementCount); + ArgumentOutOfRangeException.ThrowIfNegative(remainingCount); + ImmutableArray failed = failedExports.IsDefault ? [] : failedExports; + HashSet names = new(StringComparer.Ordinal); + foreach (string export in failed) + { + ArgumentException.ThrowIfNullOrWhiteSpace(export, nameof(failedExports)); + if (!names.Add(export)) + { + throw new ArgumentException( + $"The release outcome of Lua module '{moduleName}' names the failed export '{export}' more than once.", + nameof(failedExports)); + } + } + + bool partiallyReleased = kind == LeaseReleaseKind.PartiallyReleased; + if (partiallyReleased == failed.IsEmpty) + { + throw new ArgumentException( + $"The release outcome of Lua module '{moduleName}' must name its failed exports exactly when it is " + + $"{nameof(LeaseReleaseKind.PartiallyReleased)}.", nameof(failedExports)); + } + + if (remainingCount < failed.Length) + { + throw new ArgumentException( + $"The release outcome of Lua module '{moduleName}' cannot report fewer remaining globals than failed exports.", + nameof(remainingCount)); + } + + return new LuaModuleReleaseOutcome(moduleName, kind, removedCount, restoredCount, replacementCount, + remainingCount, failed); + } + + /// Creates the outcome of a release in which no protected read or write failed. + /// The stable module identity. + /// The globals set to nil. + /// The globals whose earlier value was put back. + /// The globals left untouched because they no longer held the module's value. + /// A outcome. + /// is . + /// is empty or white space. + /// A count is negative. + public static LuaModuleReleaseOutcome Released(string moduleName, int removedCount, int restoredCount, + int replacementCount) + { + return Create(moduleName, LeaseReleaseKind.Released, removedCount, restoredCount, replacementCount, 0, []); + } + + /// Creates the outcome of a release in which at least one global could not be released. + /// The stable module identity. + /// The globals set to nil. + /// The globals whose earlier value was put back. + /// The globals left untouched because they no longer held the module's value. + /// The globals whose release failed; at least one. + /// + /// A outcome whose is the number of + /// failed exports. + /// + /// is . + /// + /// or a failed export name is empty or white space, a failed export is listed + /// twice, or is empty. + /// + /// A count is negative. + public static LuaModuleReleaseOutcome PartiallyReleased(string moduleName, int removedCount, int restoredCount, + int replacementCount, ImmutableArray failedExports) + { + return Create(moduleName, LeaseReleaseKind.PartiallyReleased, removedCount, restoredCount, replacementCount, + failedExports.IsDefault ? 0 : failedExports.Length, failedExports); + } + + /// Creates the outcome of a release that found no registration left to release. + /// The stable module identity. + /// An outcome. + /// is . + /// is empty or white space. + public static LuaModuleReleaseOutcome AlreadyReleased(string moduleName) + { + return Create(moduleName, LeaseReleaseKind.AlreadyReleased, 0, 0, 0, 0, []); + } + + /// + /// Creates the outcome of a release refused because the registration belongs to an earlier Lua attachment or state. + /// + /// The stable module identity. + /// The globals of the registration, none of which was examined. + /// A outcome. + /// is . + /// is empty or white space. + /// is negative. + public static LuaModuleReleaseOutcome RefusedRuntimeChanged(string moduleName, int remainingCount) + { + return Create(moduleName, LeaseReleaseKind.RefusedRuntimeChanged, 0, 0, 0, remainingCount, []); + } + + /// Creates the outcome of a release that could not begin; the module keeps its registration. + /// The stable module identity. + /// The globals of the registration, none of which was examined. + /// A retryable outcome. + /// is . + /// is empty or white space. + /// is negative. + public static LuaModuleReleaseOutcome CleanupUnavailable(string moduleName, int remainingCount) + { + return Create(moduleName, LeaseReleaseKind.CleanupUnavailable, 0, 0, 0, remainingCount, []); + } + + /// + /// Formats the outcome as Module=<name>; Kind=<kind>; Removed=<n>; Restored=<n>; + /// Replacement=<n>; Remaining=<n>; Failed=<comma-separated names>. + /// + /// A culture-invariant, single-line description. + public override string ToString() + { + StringBuilder text = new(); + text.Append("Module=").Append(ModuleName) + .Append("; Kind=").Append(Kind.ToString()) + .Append("; Removed=").Append(RemovedCount.ToString(CultureInfo.InvariantCulture)) + .Append("; Restored=").Append(RestoredCount.ToString(CultureInfo.InvariantCulture)) + .Append("; Replacement=").Append(ReplacementCount.ToString(CultureInfo.InvariantCulture)) + .Append("; Remaining=").Append(RemainingCount.ToString(CultureInfo.InvariantCulture)) + .Append("; Failed=").Append(string.Join(",", FailedExports)); + return text.ToString(); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs index 0f709eb..7a4834f 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs @@ -4,6 +4,10 @@ namespace CheatEngine.Client.Lua; public readonly record struct LuaScript { /// Creates a Lua script request. + /// The Lua source text. + /// The chunk name Lua diagnostics use, or for none. + /// is . + /// is empty. public LuaScript(string source, string? chunkName = null) { ArgumentNullException.ThrowIfNull(source); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryBatchClient.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryBatchClient.cs deleted file mode 100644 index 01cfedc..0000000 --- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryBatchClient.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CheatEngine.Client.Memory; - -/// Executes primitive batches while preserving their per-operation outcome details. -/// -/// This companion contract is intentionally separate from so existing client -/// implementations remain source-compatible. Batch writes are sequential and never imply a transaction or a -/// rollback. -/// -public interface IMemoryBatchClient -{ - /// Executes a primitive read batch and returns its completed immutable value prefix and failure details. - public MemoryPrimitiveBatchReadOutcome ReadPrimitiveBatchDetailed(MemoryPrimitiveBatchReadRequest request, - CancellationToken cancellationToken = default); - - /// Executes a primitive write batch and returns its completed count and observable effect state. - public MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchDetailed(MemoryPrimitiveBatchWriteRequest request, - CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs index d078d30..b48a29f 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs @@ -7,86 +7,534 @@ namespace CheatEngine.Client.Memory; /// Executes typed target-memory reads and writes without exposing a Lua state. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Two typed routes exist, and the Client never resolves a codec implicitly. The primitive members take +/// where T : unmanaged and support exactly the 8-, 16-, 32- and 64-bit signed and unsigned integers, +/// , and (a target pointer, read and written at the +/// observed target bitness). Any other T is refused with +/// and +/// before dispatch, without a Cheat Engine call. Every other type goes through and +/// , whose request carries the to use. +/// +/// +/// Batch writes are sequential and never imply a transaction or a rollback; +/// reports how far they got. +/// +/// +/// Exceptions. Every member checks its arguments, then the activation, before any Cheat Engine call: +/// a or tampered request throws an , an ended +/// activation and a stopping one +/// . A deactivation callback can still call every member on +/// Cheat Engine's main thread (see ), but the context a codec receives there +/// throws . A Try or Detailed member returns +/// every other failure; the throwing member with the same inputs throws it through +/// . The cancellation token is observed before the +/// work is dispatched to Cheat Engine's main thread. +/// +/// public interface IMemoryClient { - /// Tries to read a built-in scalar or pointer type without requiring a custom codec. + /// Tries to read one supported primitive (see the remarks) without a codec. + /// The primitive type to read: one of the types the interface remarks list. + /// The target address to read. + /// The value read on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// when the value was read. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged; - /// Reads a built-in scalar or pointer type or throws when unsupported or rejected. - public T ReadPrimitive(Address address, CancellationToken cancellationToken = default); + /// Reads one supported primitive (see the remarks) or throws when it is refused or fails. + /// The primitive type to read: one of the types the interface remarks list. + /// The target address to read. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// The value read. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// + /// + /// The read observed the cancellation of . + /// + /// The read failed with any other failure kind. + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + where T : unmanaged; - /// Tries to write a built-in scalar or pointer type without requiring a custom codec. + /// Tries to write one supported primitive (see the remarks) without a codec. + /// The primitive type to write: one of the types the interface remarks list. + /// The target address to write. + /// The value to write. + /// The classified failure; the default value on success. + /// Observed before the write is dispatched to Cheat Engine's main thread. + /// when the value was written. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + where T : unmanaged; - /// Writes a built-in scalar or pointer type or throws when unsupported or rejected. - public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default); + /// Writes one supported primitive (see the remarks) or throws when it is refused or fails. + /// The primitive type to write: one of the types the interface remarks list. + /// The target address to write. + /// The value to write. + /// Observed before the write is dispatched to Cheat Engine's main thread. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// + /// + /// The write observed the cancellation of . + /// + /// The write failed with any other failure kind. + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + where T : unmanaged; - /// Tries to read a bounded homogeneous batch of built-in scalars or pointers in one dispatch admission. + /// Tries to read a bounded homogeneous batch of one supported primitive in one dispatch admission. + /// The primitive type to read: one of the types the interface remarks list. + /// The addresses to read, in order. + /// The values read, in request order, on success; otherwise an empty array. + /// The classified failure; the default value on success. + /// Observed before the batch is dispatched to Cheat Engine's main thread. + /// when every address was read. + /// + /// is the request, which has no address, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, out ImmutableArray values, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged; - /// Reads a bounded homogeneous batch of built-in scalars or pointers or throws on failure. + /// Reads a bounded homogeneous batch of one supported primitive or throws on failure. + /// The primitive type to read: one of the types the interface remarks list. + /// The addresses to read, in order. + /// Observed before the batch is dispatched to Cheat Engine's main thread. + /// The values read, in request order. + /// + /// is the request, which has no address, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the batch failed with + /// . + /// + /// + /// The batch observed the cancellation of . + /// + /// The batch failed with any other failure kind. public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + where T : unmanaged; + + /// + /// Reads a bounded homogeneous batch of one supported primitive and returns its completed immutable value prefix + /// and failure details. + /// + /// The primitive type to read: one of the types the interface remarks list. + /// The addresses to read, in order. + /// Observed before the batch is dispatched to Cheat Engine's main thread. + /// + /// The outcome: the values read before the first failed read, in request order, and the failure when the batch + /// did not complete. + /// + /// Like , it returns every failure instead of throwing. + /// + /// is the request, which has no address, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public MemoryPrimitiveBatchReadOutcome ReadPrimitiveBatchDetailed(MemoryPrimitiveBatchReadRequest request, + CancellationToken cancellationToken = default) + where T : unmanaged; - /// Tries to write a bounded homogeneous batch of built-in scalars or pointers in one dispatch admission. + /// Tries to write a bounded homogeneous batch of one supported primitive in one dispatch admission. + /// The primitive type to write: one of the types the interface remarks list. + /// The addresses and values to write, in order. + /// + /// The classified failure; the default value on success. The writes that completed before a failure stay in + /// place; reports how many. + /// + /// Observed before the batch is dispatched to Cheat Engine's main thread. + /// when every value was written. + /// + /// is the request, which has no value, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + where T : unmanaged; - /// Writes a bounded homogeneous batch of built-in scalars or pointers or throws on failure. + /// Writes a bounded homogeneous batch of one supported primitive or throws on failure. + /// The primitive type to write: one of the types the interface remarks list. + /// The addresses and values to write, in order. + /// Observed before the batch is dispatched to Cheat Engine's main thread. + /// + /// is the request, which has no value, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the batch failed with + /// . + /// + /// + /// The batch observed the cancellation of . + /// + /// + /// The batch failed with any other failure kind; the writes that completed before the failure stay in place. + /// public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + where T : unmanaged; + + /// + /// Writes a bounded homogeneous batch of one supported primitive and returns its completed count and observable + /// effect state. + /// + /// The primitive type to write: one of the types the interface remarks list. + /// The addresses and values to write, in order. + /// Observed before the batch is dispatched to Cheat Engine's main thread. + /// + /// The outcome: how many writes completed in order, the effect state, and the failure when the batch did not + /// complete. + /// + /// Like , it returns every failure instead of throwing. + /// + /// is the request, which has no value, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchDetailed(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken = default) + where T : unmanaged; /// Tries to copy an exact, caller-bounded byte range. + /// The first address and the exact number of bytes to copy. + /// + /// Every requested byte on success; otherwise an empty array, even when Cheat Engine confirmed a prefix + /// ( keeps it). + /// + /// The classified failure; the default value on success. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// when every requested byte was copied. + /// + /// is the request, whose length is zero, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Copies an exact byte range or throws on an expected host failure. + /// The first address and the exact number of bytes to copy. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// Every requested byte. + /// + /// is the request, whose length is zero, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// + /// + /// The read observed the cancellation of . + /// + /// + /// The read failed with any other failure kind, a partial read included. + /// public ImmutableArray ReadBytes(MemoryBytesReadRequest request, CancellationToken cancellationToken = default); + /// + /// Copies a caller-bounded byte range and reports the contiguous prefix Cheat Engine confirmed when it returned fewer + /// bytes than requested. + /// + /// The first address and the exact number of bytes to copy. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// + /// The outcome: the confirmed prefix, the requested length, and the failure when the read did not complete. + /// + /// + /// Like a Try method, it reports an expected host failure, a budget refusal and a cancellation before dispatch + /// through instead of throwing them; + /// reports the same read without its prefix. + /// + /// + /// is the request, whose length is zero, or a tampered + /// one. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public MemoryBytesReadOutcome ReadBytesDetailed(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default); + /// Tries to write an immutable byte request. + /// The first address and the bytes to write. + /// The classified failure; the default value on success. + /// Observed before the write is dispatched to Cheat Engine's main thread. + /// when every byte was written. + /// + /// is the request, which has no byte. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Writes bytes or throws on an expected host failure. + /// The first address and the bytes to write. + /// Observed before the write is dispatched to Cheat Engine's main thread. + /// + /// is the request, which has no byte. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// + /// + /// The write observed the cancellation of . + /// + /// The write failed with any other failure kind. public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default); - /// Tries to read a string with an explicit maximum length. + /// + /// Tries to read a string bounded by , which Cheat Engine + /// receives unchanged as a host-side bound of undocumented unit. + /// + /// The first address, the host-side bound and the target encoding. + /// The text read on success; otherwise . + /// The classified failure; the default value on success. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// when the string was read. + /// + /// is the request, whose maximum length is zero, or a + /// tampered one whose encoding is not a defined value. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Reads a bounded string or throws on an expected host failure. + /// The first address, the host-side bound and the target encoding. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// The text read. + /// + /// is the request, whose maximum length is zero, or a + /// tampered one whose encoding is not a defined value. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// + /// + /// The read observed the cancellation of . + /// + /// The read failed with any other failure kind. public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default); - /// Tries to write managed text with an explicit narrow/wide target representation. + /// Tries to write managed text with the explicit of its request. + /// The first address, the text, its encoded-length bound and the target encoding. + /// The classified failure; the default value on success. + /// Observed before the write is dispatched to Cheat Engine's main thread. + /// when the text was written. + /// + /// is the request, which has no text. + /// + /// + /// is a tampered request that its constructor refuses: a bound that is not positive + /// or an undefined encoding (), or a text longer than its bound. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Writes managed text or throws on an expected host failure. + /// The first address, the text, its encoded-length bound and the target encoding. + /// Observed before the write is dispatched to Cheat Engine's main thread. + /// + /// is the request, which has no text. + /// + /// + /// is a tampered request that its constructor refuses: a bound that is not positive + /// or an undefined encoding (), or a text longer than its bound. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// + /// + /// The write observed the cancellation of . + /// + /// The write failed with any other failure kind. public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default); /// Tries to resolve a finite target-aware pointer chain. + /// The base address and the offset applied after each pointer read. + /// The address the last offset designates on success; otherwise the default value. + /// + /// The classified failure, which names the hop of a failed pointer read; the default value on success. + /// + /// Observed before the chain is dispatched to Cheat Engine's main thread. + /// when every pointer of the chain was read. + /// + /// is the request, which has no offset, or a tampered + /// one with more than 64 offsets (). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryResolvePointerChain(PointerChainRequest request, out Address address, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Resolves a finite pointer chain or throws on an expected host failure. + /// The base address and the offset applied after each pointer read. + /// Observed before the chain is dispatched to Cheat Engine's main thread. + /// The address that the last offset designates. + /// + /// is the request, which has no offset, or a tampered + /// one with more than 64 offsets (). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the chain failed with + /// . + /// + /// + /// The chain observed the cancellation of . + /// + /// The chain failed with any other failure kind. public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default); - /// Tries to read one typed value from target memory. + /// Tries to read one typed value with the codec its request carries. + /// The type of the value the codec reads. + /// The target address and the codec that reads the value. + /// The value read on success; otherwise the default value. + /// + /// The classified failure: the one the codec returned, or the Client's classification when the codec + /// returned the one. The default value on success. + /// + /// Observed before the codec is dispatched to Cheat Engine's main thread. + /// when the codec read the value. + /// An exception the codec throws propagates unchanged, as the same instance. + /// + /// is the request, which carries no codec. It is thrown + /// before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the codec uses its context). + /// public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Reads one typed value or throws when the operation fails. + /// Reads one typed value with the codec its request carries, or throws when the operation fails. + /// The type of the value the codec reads. + /// The target address and the codec that reads the value. + /// Observed before the codec is dispatched to Cheat Engine's main thread. + /// The value the codec read. + /// An exception the codec throws propagates unchanged, as the same instance. + /// + /// is the request, which carries no codec. It is thrown + /// before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the codec uses its context), + /// or the read failed with . + /// + /// + /// The read observed the cancellation of . + /// + /// The read failed with any other failure kind. public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default); - /// Tries to write one typed value to target memory. + /// Tries to write one typed value with the codec its request carries. + /// The type of the value the codec writes. + /// The target address, the value and the codec that writes it. + /// + /// The classified failure: the one the codec returned, or the Client's classification when the codec + /// returned the one. The default value on success. A codec that wrote several + /// times before it failed leaves its earlier writes in place. + /// + /// Observed before the codec is dispatched to Cheat Engine's main thread. + /// when the codec wrote the value. + /// An exception the codec throws propagates unchanged, as the same instance. + /// + /// is the request, which carries no codec. It is thrown + /// before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the codec uses its context). + /// public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Writes one typed value or throws when the operation fails. + /// Writes one typed value with the codec its request carries, or throws when the operation fails. + /// The type of the value the codec writes. + /// The target address, the value and the codec that writes it. + /// Observed before the codec is dispatched to Cheat Engine's main thread. + /// An exception the codec throws propagates unchanged, as the same instance. + /// + /// is the request, which carries no codec. It is thrown + /// before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the codec uses its context), + /// or the write failed with . + /// + /// + /// The write observed the cancellation of . + /// + /// The write failed with any other failure kind. public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs index 4e96201..2534eba 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Memory; @@ -7,14 +8,40 @@ namespace CheatEngine.Client.Memory; /// Maps one managed value type to bounded target-memory operations. /// The managed value type. /// -/// Implementations must be deterministic, allocation-conscious, AOT-safe, and independent of Lua or CE object -/// handles. A codec is invoked synchronously on Cheat Engine's dispatch boundary and must not retain its context. +/// +/// Implementable. Applications implement this interface and the Client calls it. Its members are frozen +/// for the 1.x line. +/// +/// +/// Implementations must be deterministic, allocation-conscious, AOT-safe, and independent of Lua or CE object +/// handles. A codec is invoked synchronously on Cheat Engine's dispatch boundary and must not retain its +/// context. +/// /// public interface IMemoryCodec { /// Tries to read one value from the requested target address. - public bool TryRead(IMemoryReadContext context, Address address, [MaybeNullWhen(false)] out T value); + /// The bounded read context of this invocation; never retain it. + /// The target address. + /// The value read when the method returns . + /// + /// When the method returns : the classified failure the Client publishes unchanged, + /// including its host effect (a failure a context method returned can be passed on as is), or the + /// value to let the Client classify the failure from what the context observed. + /// + /// when the value was read. + public bool TryRead(IMemoryReadContext context, Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure); /// Tries to write one value to the requested target address. - public bool TryWrite(IMemoryWriteContext context, Address address, in T value); + /// The bounded write context of this invocation; never retain it. + /// The target address. + /// The value to write. + /// + /// When the method returns : the classified failure the Client publishes unchanged, + /// including its host effect, or the value to let the Client classify the failure from + /// what the context observed. + /// + /// when the value was written. + public bool TryWrite(IMemoryWriteContext context, Address address, in T value, out CheatEngineFailure failure); } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs index 1b87aa5..85b995a 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs @@ -1,21 +1,135 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Memory; -/// Provides the bounded raw-memory operations available to an application read codec. +/// +/// Provides the bounded raw-memory operations and target width facts available to an application read codec. +/// /// -/// The Client invalidates this context immediately when the codec invocation returns or throws. Codecs must not -/// retain the context; a later member access throws -/// . +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// The Client invalidates this context immediately when the codec invocation returns or throws. Codecs must not +/// retain the context; a later member access throws +/// . From a deactivation +/// callback, where the memory client still admits the codec read itself (see +/// ), every member throws , +/// since the activation is stopping. +/// +/// +/// The width facts are observed once per codec invocation and expire with the context. Cheat Engine's +/// readPointer follows the bitness, not the configured pointer size (a Lua-only host observation; not +/// host-qualified); what the configured size affects besides the value Cheat Engine reports is not established. +/// The Client's own pointer-typed operations are refused when the bitness is unknown or the two differ; an +/// application codec decides for itself. The Client ships no codec, so a codec also chooses the byte order it +/// reads; the local x86/x64 host profile is little-endian. +/// /// public interface IMemoryReadContext { - /// Gets the selected target's pointer size in bytes. - public int PointerSize + /// + /// Gets the bitness of the selected target: the process width Cheat Engine's readPointer follows, never the + /// plugin's own width, or when no target is selected or the width could not + /// be observed. + /// + /// + /// When the bitness is unknown and the codec then returns , the Client reports why: + /// when no target is selected, + /// otherwise the kind of the status Cheat Engine reported. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts; the exception type follows the kind of the failure. The + /// Client reports this exception as the codec operation's failure when the codec lets it propagate. + /// + public PointerSize Bitness + { + get; + } + + /// + /// Gets Cheat Engine's configured pointer size as a width when it is 4 or 8 bytes, otherwise + /// . It is per-attachment Cheat Engine state, independent of the bitness. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts. + /// + public PointerSize ConfiguredPointerSize + { + get; + } + + /// + /// Gets the raw value of Cheat Engine's configured pointer size, or when it was not + /// observed. It can hold a value other than 4 or 8. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts. + /// + public int? ConfiguredPointerSizeBytes + { + get; + } + + /// + /// Gets whether the observed configured pointer size differs from a known bitness; when + /// either value is unknown, which is no evidence of a mismatch. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts. + /// + public bool? ConfiguredPointerSizeDiffersFromBitness { get; } /// Tries to fill the exact caller-provided buffer from target memory. - public bool TryReadBytes(Address address, Span destination); + /// The first target address. + /// The buffer to fill completely. + /// + /// The classified failure of this read when the method returns ; the default value on + /// success. A codec that fails because of this read can return it unchanged. + /// + /// + /// when every byte was read; otherwise , with the buffer cleared. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + public bool TryReadBytes(Address address, Span destination, out CheatEngineFailure failure); } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs index 2c2a31d..ebe95d7 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs @@ -1,21 +1,133 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Memory; -/// Provides the bounded raw-memory operations available to an application write codec. +/// +/// Provides the bounded raw-memory operations and target width facts available to an application write codec. +/// /// -/// The Client invalidates this context immediately when the codec invocation returns or throws. Codecs must not -/// retain the context; a later member access throws -/// . +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// The Client invalidates this context immediately when the codec invocation returns or throws. Codecs must not +/// retain the context; a later member access throws +/// . From a deactivation +/// callback, where the memory client still admits the codec write itself (see +/// ), every member throws , +/// since the activation is stopping. +/// +/// +/// The width facts are observed once per codec invocation and expire with the context. Cheat Engine's +/// readPointer follows the bitness, not the configured pointer size (a Lua-only host observation; not +/// host-qualified); what the configured size affects besides the value Cheat Engine reports is not established. +/// The Client's own pointer-typed operations are refused when the bitness is unknown or the two differ; an +/// application codec decides for itself. The Client ships no codec, so a codec also chooses the byte order it +/// writes; the local x86/x64 host profile is little-endian. +/// /// public interface IMemoryWriteContext { - /// Gets the selected target's pointer size in bytes. - public int PointerSize + /// + /// Gets the bitness of the selected target: the process width Cheat Engine's readPointer follows, never the + /// plugin's own width, or when no target is selected or the width could not + /// be observed. + /// + /// + /// When the bitness is unknown and the codec then returns , the Client reports why: + /// when no target is selected, + /// otherwise the kind of the status Cheat Engine reported. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts; the exception type follows the kind of the failure. The + /// Client reports this exception as the codec operation's failure when the codec lets it propagate. + /// + public PointerSize Bitness + { + get; + } + + /// + /// Gets Cheat Engine's configured pointer size as a width when it is 4 or 8 bytes, otherwise + /// . It is per-attachment Cheat Engine state, independent of the bitness. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts. + /// + public PointerSize ConfiguredPointerSize + { + get; + } + + /// + /// Gets the raw value of Cheat Engine's configured pointer size, or when it was not + /// observed. It can hold a value other than 4 or 8. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts. + /// + public int? ConfiguredPointerSizeBytes + { + get; + } + + /// + /// Gets whether the observed configured pointer size differs from a known bitness; when + /// either value is unknown, which is no evidence of a mismatch. + /// + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + /// + /// Cheat Engine could not be asked for the target facts. + /// + public bool? ConfiguredPointerSizeDiffersFromBitness { get; } /// Tries to write the exact caller-provided bytes to target memory. - public bool TryWriteBytes(Address address, ReadOnlySpan source); + /// The first target address. + /// The bytes to write. + /// + /// The classified failure of this write when the method returns ; the default value on + /// success. A codec that fails because of this write can return it unchanged. + /// + /// when every byte was written. + /// + /// The context is used after its codec invocation returned or threw, or on another thread, or the activation + /// ended, or it is stopping and the codec does not run from a deactivation callback. + /// + /// + /// The activation is stopping and the codec runs from a deactivation callback. + /// + public bool TryWriteBytes(Address address, ReadOnlySpan source, out CheatEngineFailure failure); } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs index 9d319b6..48f8bfd 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs @@ -3,8 +3,11 @@ namespace CheatEngine.Client.Memory; /// Associates one copied managed value with its target address. -/// The homogeneous scalar type written by a batch. +/// +/// The homogeneous primitive type written by a batch, one of the types supports. +/// public readonly struct MemoryAddressValue + where T : unmanaged { /// Creates one copied address/value pair. /// The target address. diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs index 1327380..4ed053c 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs @@ -5,8 +5,17 @@ public static class MemoryBatchLimits { /// Gets the largest number of homogeneous scalar operations accepted in one batch. /// - /// The limit bounds request copying, result materialization and the amount of work admitted to one Cheat Engine - /// main-thread dispatch. It does not make the individual Lua globals atomic. + /// + /// This is the hard request count per batch: the batch request constructors reject a longer list, and + /// can tighten it for one activation but never + /// raise it. + /// + /// + /// The limit bounds request copying, result materialization and the amount of work admitted to one Cheat + /// Engine main-thread dispatch. It does not make the individual Lua globals atomic: the operations run in + /// order, a write batch is never rolled back, and its partial-effect state is reported by + /// with the completed prefix length. + /// /// - public const int MaximumOperations = 1024; + public const int MaximumOperationCount = 1024; } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchWriteEffectState.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchWriteEffectState.cs index edd3d3a..f7976ff 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchWriteEffectState.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchWriteEffectState.cs @@ -3,15 +3,18 @@ namespace CheatEngine.Client.Memory; /// Describes the observable target-memory effect of a sequential primitive batch write. public enum MemoryBatchWriteEffectState { + /// + /// The Client could not establish whether the target observed any write. It is also the value of an unassigned + /// state, so an uninitialized outcome never reads as an established effect. + /// + Unknown = 0, + /// No write is known to have reached the target. - NotStarted = 0, + NotStarted = 1, /// A strict prefix completed before a later write failed. - Partial = 1, + Partial = 2, /// Every requested write completed. - Complete = 2, - - /// The dispatcher could not establish whether the target observed any write. - Unknown = 3 + Completed = 3 } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadOutcome.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadOutcome.cs new file mode 100644 index 0000000..c472160 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadOutcome.cs @@ -0,0 +1,81 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Memory; + +/// +/// Describes one bounded byte read, including the contiguous prefix that Cheat Engine confirmed when the read did not +/// complete. +/// +/// +/// +/// CheatEngine.SDK verifies every byte it copies. When Cheat Engine returns fewer bytes than requested, the read +/// fails with and holds the confirmed +/// prefix, so a partial copy is never confused with a host failure that copied nothing. A failure observed before +/// Cheat Engine returned anything (a budget, a cancellation, an unavailable global) has an empty prefix. +/// +/// +/// The prefix is a copy owned by the caller; it never aliases Cheat Engine or Lua memory. +/// +/// +public sealed class MemoryBytesReadOutcome +{ + /// Creates a byte-read outcome. + /// The positive number of bytes the request asked for. + /// + /// The confirmed contiguous prefix: every requested byte when is + /// , otherwise at most minus one bytes. A default array + /// is an empty prefix. + /// + /// The failure, or when every requested byte was copied. + /// + /// is zero or negative, or is longer than it. + /// + /// + /// A successful outcome is incomplete, or a failed outcome holds every requested byte. + /// + public MemoryBytesReadOutcome(int requestedLength, ImmutableArray bytes, CheatEngineFailure? failure) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requestedLength); + ImmutableArray prefix = bytes.IsDefault ? [] : bytes; + ArgumentOutOfRangeException.ThrowIfGreaterThan(prefix.Length, requestedLength, nameof(bytes)); + if (failure is null && prefix.Length != requestedLength) + { + throw new ArgumentException("An incomplete byte read requires a failure.", nameof(failure)); + } + + if (failure is not null && prefix.Length == requestedLength) + { + throw new ArgumentException("A complete byte read cannot contain a failure.", nameof(failure)); + } + + RequestedLength = requestedLength; + Bytes = prefix; + Failure = failure; + } + + /// Gets the confirmed contiguous prefix: every requested byte when the read succeeded. + public ImmutableArray Bytes + { + get; + } + + /// Gets the number of bytes the request asked for. + public int RequestedLength + { + get; + } + + /// Gets the number of bytes Cheat Engine confirmed, the length of . + public int ConfirmedLength => Bytes.Length; + + /// Gets the failure when the read did not complete, or . + public CheatEngineFailure? Failure + { + get; + } + + /// Gets whether the read succeeded: no failure, and every requested byte confirmed. + public bool IsSuccess => Failure is null; +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs index 37736cb..b3f062b 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs @@ -6,6 +6,9 @@ namespace CheatEngine.Client.Memory; public readonly record struct MemoryBytesReadRequest { /// Creates a bounded byte read. + /// The first target address. + /// The positive number of bytes to copy. + /// is zero or negative. public MemoryBytesReadRequest(Address address, int length) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs index a64b651..54a6cef 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs @@ -5,9 +5,12 @@ namespace CheatEngine.Client.Memory; /// A copied byte sequence to write to target memory. -public readonly record struct MemoryBytesWriteRequest +public readonly struct MemoryBytesWriteRequest { /// Creates a byte write and copies the caller's data immediately. + /// The first target address. + /// The non-empty bytes to write, copied by the constructor. + /// is empty. public MemoryBytesWriteRequest(Address address, ReadOnlySpan bytes) { if (bytes.IsEmpty) diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadOutcome.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadOutcome.cs index 2421d81..be37a0c 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadOutcome.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadOutcome.cs @@ -5,38 +5,44 @@ namespace CheatEngine.Client.Memory; /// Describes the sequential outcome of one primitive batch read. -/// The homogeneous primitive value type. +/// +/// The homogeneous primitive value type, one of the types supports. +/// public sealed class MemoryPrimitiveBatchReadOutcome + where T : unmanaged { - /// Creates a read outcome and copies the completed value prefix. - public MemoryPrimitiveBatchReadOutcome(int attemptedCount, int completedCount, int? failedIndex, - CheatEngineFailure? cause, ReadOnlySpan readPrefix) + /// Creates a read outcome and copies the values read in order. + /// The number of reads the batch requested. + /// The values read in order before the batch stopped: the confirmed prefix. + /// The read that failed, or when admission or dispatch failed. + /// The failure when the batch did not complete; otherwise. + /// + /// is zero or negative, is longer than it, or + /// is not the index that follows the values read. + /// + /// + /// is for an incomplete batch, or set for a complete one. + /// + public MemoryPrimitiveBatchReadOutcome(int requestedCount, ReadOnlySpan values, int? failedIndex, + CheatEngineFailure? failure) { - ValidateCounts(attemptedCount, completedCount, failedIndex, cause); - if (readPrefix.Length != completedCount) - { - throw new ArgumentException("The read prefix length must equal the completed operation count.", - nameof(readPrefix)); - } - - AttemptedCount = attemptedCount; - CompletedCount = completedCount; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requestedCount); + ArgumentOutOfRangeException.ThrowIfGreaterThan(values.Length, requestedCount, nameof(values)); + ValidateFailure(requestedCount, values.Length, failedIndex, failure); + RequestedCount = requestedCount; FailedIndex = failedIndex; - Cause = cause; - ReadPrefix = ImmutableArray.Create(readPrefix.ToArray()); + Failure = failure; + Values = ImmutableArray.Create(values); } /// Gets the number of operations requested by the batch. - public int AttemptedCount + public int RequestedCount { get; } - /// Gets the number of reads known to have completed in order. - public int CompletedCount - { - get; - } + /// Gets the number of reads known to have completed in order: the length of . + public int CompletedCount => Values.Length; /// Gets the individual read index that failed, or when admission or dispatch failed. public int? FailedIndex @@ -45,42 +51,39 @@ public int? FailedIndex } /// Gets the expected admission, dispatch, or target-memory failure when the batch did not complete. - public CheatEngineFailure? Cause + public CheatEngineFailure? Failure { get; } - /// Gets an immutable copy of the successfully read values before . - public ImmutableArray ReadPrefix + /// + /// Gets an immutable copy of the values read in order before : the confirmed prefix, and + /// every value on success, as the values output of . + /// + public ImmutableArray Values { get; } /// Gets whether every requested read completed successfully. - public bool Succeeded => Cause is null && CompletedCount == AttemptedCount; + public bool IsSuccess => Failure is null && CompletedCount == RequestedCount; - private static void ValidateCounts(int attemptedCount, int completedCount, int? failedIndex, - CheatEngineFailure? cause) + private static void ValidateFailure(int requestedCount, int completedCount, int? failedIndex, + CheatEngineFailure? failure) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(attemptedCount); - if (completedCount < 0 || completedCount > attemptedCount) - { - throw new ArgumentOutOfRangeException(nameof(completedCount)); - } - - if (failedIndex is { } index && (index < 0 || index >= attemptedCount || index != completedCount)) + if (failedIndex is { } index && (index < 0 || index >= requestedCount || index != completedCount)) { throw new ArgumentOutOfRangeException(nameof(failedIndex)); } - if (cause is null && (failedIndex is not null || completedCount != attemptedCount)) + if (failure is null && (failedIndex is not null || completedCount != requestedCount)) { - throw new ArgumentException("An incomplete read outcome requires a failure cause.", nameof(cause)); + throw new ArgumentException("An incomplete read outcome requires a failure.", nameof(failure)); } - if (cause is not null && completedCount == attemptedCount) + if (failure is not null && completedCount == requestedCount) { - throw new ArgumentException("A completed read outcome cannot contain a failure cause.", nameof(cause)); + throw new ArgumentException("A completed read outcome cannot contain a failure.", nameof(failure)); } } } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs index 65fcb26..2e3855c 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs @@ -5,8 +5,11 @@ namespace CheatEngine.Client.Memory; /// A copied, bounded request to read one homogeneous built-in scalar from multiple target addresses. -/// The built-in scalar or target-aware pointer type to read. +/// +/// The built-in scalar or target-aware pointer type to read, one of the types supports. +/// public readonly struct MemoryPrimitiveBatchReadRequest + where T : unmanaged { /// Creates a bounded batch by copying every target address. /// The non-empty target addresses to read in order. @@ -19,10 +22,10 @@ public MemoryPrimitiveBatchReadRequest(ReadOnlySpan
addresses) throw new ArgumentException("A memory batch requires at least one address.", nameof(addresses)); } - if (addresses.Length > MemoryBatchLimits.MaximumOperations) + if (addresses.Length > MemoryBatchLimits.MaximumOperationCount) { throw new ArgumentOutOfRangeException(nameof(addresses), - $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperationCount} operations."); } Addresses = ImmutableArray.Create(addresses.ToArray()); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteOutcome.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteOutcome.cs index 20a3997..92272c6 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteOutcome.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteOutcome.cs @@ -6,19 +6,37 @@ namespace CheatEngine.Client.Memory; public sealed class MemoryPrimitiveBatchWriteOutcome { /// Creates a write outcome with a known or explicitly unknown target effect state. - public MemoryPrimitiveBatchWriteOutcome(int attemptedCount, int completedCount, int? failedIndex, - CheatEngineFailure? cause, MemoryBatchWriteEffectState effectState) + /// The positive number of writes the batch requested. + /// The number of writes known to have completed in order. + /// + /// The write that failed, which is , or when it is + /// not known. + /// + /// The failure when the batch did not complete; otherwise. + /// What is known about the writes' effect on the target. + /// + /// is zero or negative, is negative or + /// larger than it, is not a defined value, or + /// is not . + /// + /// + /// The values contradict each other: is for an incomplete + /// batch or set for a complete one, a state has completed + /// writes, or a state has none or all of them. + /// + public MemoryPrimitiveBatchWriteOutcome(int requestedCount, int completedCount, int? failedIndex, + CheatEngineFailure? failure, MemoryBatchWriteEffectState effectState) { - Validate(attemptedCount, completedCount, failedIndex, cause, effectState); - AttemptedCount = attemptedCount; + Validate(requestedCount, completedCount, failedIndex, failure, effectState); + RequestedCount = requestedCount; CompletedCount = completedCount; FailedIndex = failedIndex; - Cause = cause; + Failure = failure; EffectState = effectState; } /// Gets the number of operations requested by the batch. - public int AttemptedCount + public int RequestedCount { get; } @@ -36,7 +54,7 @@ public int? FailedIndex } /// Gets the expected admission, dispatch, or target-memory failure when the batch did not complete. - public CheatEngineFailure? Cause + public CheatEngineFailure? Failure { get; } @@ -48,13 +66,13 @@ public MemoryBatchWriteEffectState EffectState } /// Gets whether every requested write completed successfully. - public bool Succeeded => Cause is null && EffectState == MemoryBatchWriteEffectState.Complete; + public bool IsSuccess => Failure is null && EffectState == MemoryBatchWriteEffectState.Completed; - private static void Validate(int attemptedCount, int completedCount, int? failedIndex, - CheatEngineFailure? cause, MemoryBatchWriteEffectState effectState) + private static void Validate(int requestedCount, int completedCount, int? failedIndex, + CheatEngineFailure? failure, MemoryBatchWriteEffectState effectState) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(attemptedCount); - if (completedCount < 0 || completedCount > attemptedCount) + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requestedCount); + if (completedCount < 0 || completedCount > requestedCount) { throw new ArgumentOutOfRangeException(nameof(completedCount)); } @@ -64,20 +82,20 @@ private static void Validate(int attemptedCount, int completedCount, int? failed throw new ArgumentOutOfRangeException(nameof(effectState)); } - if (failedIndex is { } index && (index < 0 || index >= attemptedCount || index != completedCount)) + if (failedIndex is { } index && (index < 0 || index >= requestedCount || index != completedCount)) { throw new ArgumentOutOfRangeException(nameof(failedIndex)); } - if (cause is null && (completedCount != attemptedCount || effectState != MemoryBatchWriteEffectState.Complete)) + if (failure is null && (completedCount != requestedCount || effectState != MemoryBatchWriteEffectState.Completed)) { - throw new ArgumentException("An incomplete write outcome requires a failure cause.", nameof(cause)); + throw new ArgumentException("An incomplete write outcome requires a failure.", nameof(failure)); } - if (cause is not null && - (effectState == MemoryBatchWriteEffectState.Complete || completedCount == attemptedCount)) + if (failure is not null && + (effectState == MemoryBatchWriteEffectState.Completed || completedCount == requestedCount)) { - throw new ArgumentException("A completed write outcome cannot contain a failure cause.", nameof(cause)); + throw new ArgumentException("A completed write outcome cannot contain a failure.", nameof(failure)); } if (effectState == MemoryBatchWriteEffectState.NotStarted && completedCount != 0) @@ -87,7 +105,7 @@ private static void Validate(int attemptedCount, int completedCount, int? failed } if (effectState == MemoryBatchWriteEffectState.Partial && - (completedCount == 0 || completedCount == attemptedCount)) + (completedCount == 0 || completedCount == requestedCount)) { throw new ArgumentException("A partial write outcome requires a strict completed prefix.", nameof(effectState)); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs index 5e8d6ec..a22921f 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs @@ -3,8 +3,11 @@ namespace CheatEngine.Client.Memory; /// A copied, bounded request to write one homogeneous built-in scalar to multiple target addresses. -/// The built-in scalar or target-aware pointer type to write. +/// +/// The built-in scalar or target-aware pointer type to write, one of the types supports. +/// public readonly struct MemoryPrimitiveBatchWriteRequest + where T : unmanaged { /// Creates a bounded batch by copying every address/value pair. /// The non-empty target writes to perform in order. @@ -17,10 +20,10 @@ public MemoryPrimitiveBatchWriteRequest(ReadOnlySpan> valu throw new ArgumentException("A memory batch requires at least one value.", nameof(values)); } - if (values.Length > MemoryBatchLimits.MaximumOperations) + if (values.Length > MemoryBatchLimits.MaximumOperationCount) { throw new ArgumentOutOfRangeException(nameof(values), - $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperationCount} operations."); } Values = ImmutableArray.Create(values.ToArray()); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs index 23d0d7b..4f95c3b 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs @@ -3,9 +3,17 @@ namespace CheatEngine.Client.Memory; /// An immutable typed target-memory read request. +/// The type of the value the codec reads. +/// +/// A request carries no codec: throws an +/// for it before the activation check and before any Cheat Engine call. +/// public readonly record struct MemoryReadRequest { /// Creates a memory read request. + /// The target address to read. + /// The codec that reads the value. + /// is . public MemoryReadRequest(Address address, IMemoryCodec codec) { ArgumentNullException.ThrowIfNull(codec); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryResourceLimits.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryResourceLimits.cs index 8803052..9ee6325 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryResourceLimits.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryResourceLimits.cs @@ -2,8 +2,57 @@ namespace CheatEngine.Client.Memory; /// Defines the memory-work budgets captured for one Client activation. /// -/// Configuration can populate this mutable value before activation. The Core client copies and validates it when it -/// is created, so a later configuration mutation cannot change an active client's admission policy. +/// +/// Configuration can populate this mutable value before activation. The Core client copies and validates it +/// when it is created, so a later configuration mutation cannot change an active client's admission policy. +/// +/// +/// Byte, string and batch requests are admitted before dispatch: a request over a budget fails with +/// and +/// , and Cheat Engine is not called. The reads and writes +/// a custom codec makes through its context are charged cumulatively against the read and write budgets during +/// one codec call; the first one over budget fails that codec call before it reaches Cheat Engine. +/// +/// The Client documentation uses four terms for these limits: +/// +/// +/// Term +/// Where it is enforced +/// +/// +/// Maximum block size +/// +/// , and +/// : the largest contiguous block that one byte, codec or string +/// operation may copy. +/// +/// +/// +/// Request count per batch +/// +/// , which can tighten but never raise +/// . +/// +/// +/// +/// Maximum scratch allocation +/// +/// The largest managed buffer the Client allocates for one operation: the byte array of a byte read +/// (at most ) and the value array of a primitive batch read (at most +/// ). These operations allocate no memory in the target process. +/// +/// +/// +/// Partial-effect state +/// +/// Not a budget: a batch write runs its operations in order and is never rolled back, so its outcome +/// reports , +/// (with the completed prefix length), +/// or +/// . +/// +/// +/// /// public sealed class MemoryResourceLimits { @@ -20,7 +69,7 @@ public sealed class MemoryResourceLimits public const int DefaultMaximumBatchPayloadBytes = 65_536; /// Gets the default maximum number of operations represented by one primitive batch. - public const int DefaultMaximumBatchOperationCount = MemoryBatchLimits.MaximumOperations; + public const int DefaultMaximumBatchOperationCount = MemoryBatchLimits.MaximumOperationCount; /// Initializes the default activation memory budgets. public MemoryResourceLimits() @@ -28,6 +77,18 @@ public MemoryResourceLimits() } /// Initializes explicitly bounded activation memory budgets. + /// The positive maximum number of bytes one read may copy. + /// The positive maximum number of bytes one write may copy. + /// The positive maximum encoded bytes of one string operation. + /// The positive maximum payload bytes of one primitive batch. + /// + /// The positive maximum number of operations of one primitive batch, at most + /// . + /// + /// + /// A value is zero or negative, or exceeds + /// . + /// public MemoryResourceLimits(int maximumReadBytes, int maximumWriteBytes, int maximumStringBytes, int maximumBatchPayloadBytes, int maximumBatchOperationCount) { @@ -59,6 +120,11 @@ public int MaximumWriteBytes } = DefaultMaximumWriteBytes; /// Gets or sets the maximum encoded bytes admitted for one target-string operation. + /// + /// A write is charged its encoded byte length. A read is charged conservatively from + /// : that value as bytes for UTF-8, twice it for UTF-16, + /// because Cheat Engine does not document the unit of its readString limit. + /// public int MaximumStringBytes { get; @@ -66,6 +132,7 @@ public int MaximumStringBytes } = DefaultMaximumStringBytes; /// Gets or sets the maximum scalar payload bytes admitted for one primitive batch. + /// The payload is the operation count multiplied by the size of the primitive element type. public int MaximumBatchPayloadBytes { get; @@ -73,20 +140,13 @@ public int MaximumBatchPayloadBytes } = DefaultMaximumBatchPayloadBytes; /// Gets or sets the maximum primitive operations admitted for one batch. - /// The value can tighten, but never raise, . + /// The value can tighten, but never raise, . public int MaximumBatchOperationCount { get; set; } = DefaultMaximumBatchOperationCount; - /// Creates an independently validated copy for an activation-bound client. - public MemoryResourceLimits CreateSnapshot() - { - return new MemoryResourceLimits(MaximumReadBytes, MaximumWriteBytes, MaximumStringBytes, - MaximumBatchPayloadBytes, MaximumBatchOperationCount); - } - private static void Validate(int value, string parameterName) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value, parameterName); @@ -95,10 +155,10 @@ private static void Validate(int value, string parameterName) private static void ValidateBatchOperationCount(int value, string parameterName) { Validate(value, parameterName); - if (value > MemoryBatchLimits.MaximumOperations) + if (value > MemoryBatchLimits.MaximumOperationCount) { throw new ArgumentOutOfRangeException(parameterName, - $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperationCount} operations."); } } } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs index 59b2361..ce979f9 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs @@ -2,16 +2,35 @@ namespace CheatEngine.Client.Memory; -/// A bounded target-string read. +/// A bounded target-string read with an explicit target encoding. +/// +/// is passed unchanged as Cheat Engine's readString maxlength argument. +/// Cheat Engine 7.7 does not document whether that argument counts bytes or characters, so treat it as a host-side +/// bound, not as a character or byte count (evidence level: ToQualify, to be confirmed on a C3 host). +/// public readonly record struct MemoryStringReadRequest { - /// Creates a bounded text read. - public MemoryStringReadRequest(Address address, int maximumLength, bool wideCharacter = false) + /// Creates a bounded text read with an explicit target encoding. + /// The first target address. + /// + /// The positive value passed unchanged as Cheat Engine's readString maxlength argument; see + /// for why its unit is not stated. + /// + /// The UTF-8 or UTF-16 target representation. + /// + /// is zero or negative, or is not defined. + /// + public MemoryStringReadRequest(Address address, int maximumLength, MemoryStringEncoding encoding) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumLength); + if (!Enum.IsDefined(encoding)) + { + throw new ArgumentOutOfRangeException(nameof(encoding)); + } + Address = address; MaximumLength = maximumLength; - WideCharacter = wideCharacter; + Encoding = encoding; } /// Gets the first target address. @@ -20,38 +39,26 @@ public Address Address get; } - /// Gets the maximum character count passed to Cheat Engine. + /// Gets the value passed unchanged as Cheat Engine's readString maxlength argument. + /// + /// + /// Cheat Engine 7.7 does not document whether maxlength counts bytes or characters, so treat this value + /// as a host-side bound (evidence level: ToQualify, to be confirmed on a C3 host). The Client neither converts + /// nor scales it before the call. + /// + /// + /// Admission is conservative: before dispatch, the Client charges this value as bytes for UTF-8 and twice this + /// value as bytes for UTF-16 against . + /// + /// public int MaximumLength { get; } - /// Gets whether Cheat Engine should interpret the target as UTF-16 text. - public bool WideCharacter - { - get; - } - /// Gets the explicit UTF-8 or UTF-16 target representation. - public MemoryStringEncoding Encoding => WideCharacter ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8; - - /// Creates a bounded text read with an explicit target encoding. - /// The first target address. - /// The positive maximum length accepted by Cheat Engine. - /// The UTF-8 or UTF-16 target representation. - /// A request that preserves the supplied encoding choice. - public static MemoryStringReadRequest Create(Address address, int maximumLength, MemoryStringEncoding encoding) + public MemoryStringEncoding Encoding { - return new MemoryStringReadRequest(address, maximumLength, ToWideCharacter(encoding)); - } - - private static bool ToWideCharacter(MemoryStringEncoding encoding) - { - return encoding switch - { - MemoryStringEncoding.Utf8 => false, - MemoryStringEncoding.Utf16 => true, - _ => throw new ArgumentOutOfRangeException(nameof(encoding)) - }; + get; } } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs index 6e3bdf5..518ceb5 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs @@ -2,24 +2,29 @@ namespace CheatEngine.Client.Memory; -/// A copied string to write to target memory. +/// A copied string to write to target memory, with an explicit target encoding and encoded-length bound. public readonly record struct MemoryStringWriteRequest { - /// Creates a text write. - public MemoryStringWriteRequest(Address address, string value, bool wideCharacter = false) - { - ArgumentNullException.ThrowIfNull(value); - Address = address; - Value = value; - MaximumLength = 0; - WideCharacter = wideCharacter; - } - - private MemoryStringWriteRequest(Address address, string value, int maximumLength, bool wideCharacter) + /// Creates a text write with an explicit maximum encoded length and target encoding. + /// The first target address. + /// The managed text to copy. + /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. + /// The UTF-8 or UTF-16 target representation. + /// is . + /// + /// is zero or negative, or is not defined. + /// + /// The encoded text exceeds . + public MemoryStringWriteRequest(Address address, string value, int maximumLength, MemoryStringEncoding encoding) { ArgumentNullException.ThrowIfNull(value); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumLength); - if (GetEncodedLength(value, wideCharacter) > maximumLength) + if (!Enum.IsDefined(encoding)) + { + throw new ArgumentOutOfRangeException(nameof(encoding)); + } + + if (GetEncodedLength(value, encoding) > maximumLength) { throw new ArgumentException("The encoded text exceeds the explicit maximum length.", nameof(value)); } @@ -27,7 +32,7 @@ private MemoryStringWriteRequest(Address address, string value, int maximumLengt Address = address; Value = value; MaximumLength = maximumLength; - WideCharacter = wideCharacter; + Encoding = encoding; } /// Gets the first target address. @@ -42,45 +47,20 @@ public string Value get; } - /// Gets the explicit encoded-length bound, or zero for the legacy unbounded constructor. + /// Gets the positive maximum number of UTF-8 bytes or UTF-16 code units the encoded text may use. public int MaximumLength { get; } - /// Gets whether Cheat Engine should write UTF-16 target text. - public bool WideCharacter - { - get; - } - /// Gets the explicit UTF-8 or UTF-16 target representation. - public MemoryStringEncoding Encoding => WideCharacter ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8; - - /// Creates a text write with an explicit maximum encoded length and target encoding. - /// The first target address. - /// The managed text to copy. - /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. - /// The UTF-8 or UTF-16 target representation. - /// A request whose encoded text is bounded before it reaches Cheat Engine. - public static MemoryStringWriteRequest CreateBounded(Address address, string value, int maximumLength, - MemoryStringEncoding encoding) + public MemoryStringEncoding Encoding { - return new MemoryStringWriteRequest(address, value, maximumLength, ToWideCharacter(encoding)); - } - - private static bool ToWideCharacter(MemoryStringEncoding encoding) - { - return encoding switch - { - MemoryStringEncoding.Utf8 => false, - MemoryStringEncoding.Utf16 => true, - _ => throw new ArgumentOutOfRangeException(nameof(encoding)) - }; + get; } - private static int GetEncodedLength(string value, bool wideCharacter) + private static int GetEncodedLength(string value, MemoryStringEncoding encoding) { - return wideCharacter ? value.Length : System.Text.Encoding.UTF8.GetByteCount(value); + return encoding == MemoryStringEncoding.Utf16 ? value.Length : System.Text.Encoding.UTF8.GetByteCount(value); } } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs index 9e91b3c..1bbe0e0 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs @@ -3,9 +3,18 @@ namespace CheatEngine.Client.Memory; /// An immutable typed target-memory write request. +/// The type of the value the codec writes. +/// +/// A request carries no codec: throws an +/// for it before the activation check and before any Cheat Engine call. +/// public readonly record struct MemoryWriteRequest { /// Creates a memory write request. + /// The target address to write. + /// The value to write. + /// The codec that writes the value. + /// is . public MemoryWriteRequest(Address address, T value, IMemoryCodec codec) { ArgumentNullException.ThrowIfNull(codec); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs index a7d2007..154de7a 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs @@ -5,9 +5,13 @@ namespace CheatEngine.Client.Memory; /// A finite pointer chain whose offsets are applied after each target-aware pointer dereference. -public readonly record struct PointerChainRequest +public readonly struct PointerChainRequest { /// Creates a bounded pointer chain by copying its offsets. + /// The address that holds the first target pointer. + /// The offsets, one per pointer read: between 1 and 64, copied by the constructor. + /// is empty. + /// has more than 64 offsets. public PointerChainRequest(Address baseAddress, ReadOnlySpan offsets) { if (offsets.IsEmpty) diff --git a/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs b/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs index 6954ab9..9fc9faf 100644 --- a/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs +++ b/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs @@ -1,11 +1,24 @@ namespace CheatEngine.Client.Modules; /// A scoped feature that participates in the Cheat Engine client lifecycle. +/// +/// Implementable. Applications implement this interface and the Client calls it. Its members are frozen for +/// the 1.x line. +/// public interface ICheatEngineClientModule { /// Runs after the client scope and Lua runtime are active. + /// The client of the activation. public void OnEnabled(ICheatEngineClient client); /// Runs before the client scope is disposed and the Lua runtime is detached. + /// The client of the activation that is stopping. + /// + /// When the plugin disables, it runs on Cheat Engine's main thread after + /// was cancelled and before the activation releases what it owns. + /// A call it makes on that thread can still read and change existing state and release leases, but cannot + /// create a lease, attach, run Lua, instructions or Auto Assembler scripts, or load or save a table: see the + /// deactivation callbacks of . + /// public void OnDisabling(ICheatEngineClient client); } diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs b/libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs deleted file mode 100644 index d7dacdc..0000000 --- a/libs/CheatEngine.Client.Abstractions/Processes/ILocalProcessDiagnostics.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Processes; - -/// Reads bounded, copied metadata from the local operating-system process catalog. -/// -/// This is an offline diagnostic contract. It neither dispatches to Cheat Engine nor observes, selects, or proves -/// a Cheat Engine target. Its values remain ordinary managed snapshots after a plugin activation ends. For each -/// operation, request validation occurs first, then cancellation is observed before catalog access and between -/// Client-managed materialization steps. -/// -public interface ILocalProcessDiagnostics -{ - /// Tries to enumerate copied local-process metadata within an explicit materialization bound. - /// Cancellation is observed before catalog access and between Client-managed materialization steps. - public bool TryGetProcesses(ProcessEnumerationRequest request, out ProcessEnumerationResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default); - - /// Enumerates copied local-process metadata within an explicit materialization bound. - public ProcessEnumerationResult GetProcesses(ProcessEnumerationRequest request, - CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs index 9994e7e..2e51842 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs @@ -4,89 +4,283 @@ namespace CheatEngine.Client.Processes; /// Reads and changes the process selected by the active Cheat Engine session. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Cheat Engine's selected target is ambient: a session that holds a process identifier does not stop the user, +/// another plugin or a script from selecting another process. The checks of this client (CheatEngine.SDK's +/// PID-bracketed observation, the process incarnation, the selection epoch) reduce that risk; they are not +/// transactions. Like CheatEngine.SDK, this client cannot see a selection that changed and changed back between +/// two observations (A-B-A): only a later observation of another PID or incarnation advances the epoch. +/// +/// +/// Every observation is a read-only CheatEngine.SDK 2.0.0 operation that reads the selected process identifier +/// before and after the target facts, and reads none of them when no target is selected: with no target opened, +/// Cheat Engine reports the same ISA family, width and pointer size as an x64 target. The ISA is the SDK's +/// derivation from Cheat Engine's x86 and ARM family facts together with its 64-bit fact, never from the 64-bit +/// fact alone; the process width is the observed bitness. A file opened as a process has no process identity. +/// +/// +/// For a local process the selection identity also includes its incarnation: the PID and the creation time that +/// CheatEngine.SDK observed together with the local backend. When the same PID denotes another process (a +/// different creation time), the selection epoch advances. A CEServer target or a target whose backend is not +/// established has no local incarnation and never receives local operating-system metadata. +/// +/// +/// Every member but and checks the +/// activation after its arguments: an ended activation throws +/// and a stopping one +/// . A deactivation callback can still read the current +/// process on Cheat Engine's main thread (see ), unless the observation finds +/// a changed target selection, which cannot advance while the activation stops; it cannot attach. A +/// Try member returns every other failure; the throwing member with the same inputs throws it through +/// . +/// +/// public interface IProcessClient { /// Tries to get a copied snapshot of the currently selected target process. + /// The copied selected process on success; otherwise the default value. + /// The classified failure; the default value on success. + /// + /// Observed before the observation is dispatched to Cheat Engine's main thread. + /// + /// when Cheat Engine's selected target was observed and copied. /// /// Returns only when Cheat Engine has no selected target. - /// Local operating-system metadata is optional enrichment; its absence leaves the Cheat Engine target snapshot - /// valid with null name and executable path. Invalid arguments, lifecycle failures, and unexpected implementation - /// exceptions are not converted into a Try result. + /// Every other status that establishes no target keeps its own kind, with + /// : when the + /// selected target changed while it was observed, + /// for a file opened as a process, and , + /// or for + /// an absent, raising or malformed selection read. Another target fact that raises or is malformed leaves that + /// fact unknown. Local operating-system metadata is optional enrichment; its absence leaves the Cheat Engine + /// target snapshot valid with null name and executable path. An SDK fault of a Cheat Engine or local-catalog + /// call is returned as a classified failure and never crosses this method. Invalid arguments and Client + /// lifecycle exceptions () are thrown. /// - public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the observation finds a + /// changed target selection). + /// + public bool TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Gets the selected process or throws when no target is attached. - public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default); + /// + /// Observed before the observation is dispatched to Cheat Engine's main thread. + /// + /// The copied selected process. + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the observation finds a + /// changed target selection), or the observation failed with + /// . + /// + /// + /// The call observed the cancellation of . + /// + /// + /// The observation failed with any other failure kind, + /// included. + /// + public ProcessSnapshot GetCurrentProcess(CancellationToken cancellationToken = default); /// - /// Re-reads Cheat Engine's selected target and advances the selection epoch when its PID or observed - /// architecture changed. + /// Re-reads Cheat Engine's selected target and advances the selection epoch when its PID or its local + /// incarnation changed, or when its observed backend, ISA or process width changed from one known value to + /// another. /// + /// The copied selected process on success; otherwise the default value. + /// The classified failure; the default value on success. + /// + /// Observed before the observation is dispatched to Cheat Engine's main thread. + /// + /// when Cheat Engine's selected target was observed and copied. /// - /// Returns and invalidates an observed selection only - /// when Cheat Engine reports no selected target. Local metadata is optional enrichment and does not establish - /// liveness or target identity. This is an observation, not an atomic process-lifetime guarantee. + /// Returns and invalidates an observed selection when + /// Cheat Engine reports no selected target; a file opened as a process also invalidates it and returns + /// . A fact that is transiently unknown for the + /// same PID neither advances the selection epoch nor replaces the last value known for that selection, so a + /// probe failure does not invalidate target-bound leases and does not weaken the selection identity to the PID + /// alone. Local metadata is optional enrichment and does not establish liveness or target identity. This is an + /// observation, not an atomic process-lifetime guarantee; exceptions follow + /// . /// + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the observation finds a + /// changed target selection). + /// public bool TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Refreshes the selected target or throws when no target is attached. + /// + /// Observed before the observation is dispatched to Cheat Engine's main thread. + /// + /// The copied selected process. + /// The activation has ended. + /// + /// The activation is stopping (outside a deactivation callback, or inside one when the observation finds a + /// changed target selection), or the observation failed with + /// . + /// + /// + /// The call observed the cancellation of . + /// + /// + /// The observation failed with any other failure kind, + /// included. + /// public ProcessSnapshot Refresh(CancellationToken cancellationToken = default); /// Tries to attach Cheat Engine to an explicit process identifier and verifies the selected target. + /// The positive identifier of the process to attach to. + /// The copied process selected after the attach on success; otherwise the default. + /// The classified failure; the default value on success. + /// Observed before the attach is dispatched to Cheat Engine's main thread. + /// when Cheat Engine selected the requested process and it was observed. + /// + /// + /// The attach is CheatEngine.SDK's SelectAndObserve: Cheat Engine's selection call, then the selected + /// process identifier read again; a normal return of the call is not success by itself. A refused attach keeps + /// the SDK status as its kind, with : + /// when Cheat Engine did not confirm the requested + /// process, when it then reported no target, + /// for a file opened as a process, + /// when the selection changed again before it was observed, + /// and , + /// or for + /// an absent, raising or malformed global. The selection is observed again after a refusal, so the selection + /// epoch follows whatever Cheat Engine now selects. + /// + /// + /// Attaching resets Cheat Engine's configured pointer size to the target default, so an attach silently undoes + /// an earlier pointer-size override. A fault of the attach call is returned with + /// ; exceptions otherwise follow + /// . + /// + /// + /// is not positive. + /// The activation has ended. + /// The activation is stopping. public bool TryAttach(TargetProcessId processId, out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Attaches to an explicit process identifier or throws when the host rejects it. + /// The positive identifier of the process to attach to. + /// Observed before the attach is dispatched to Cheat Engine's main thread. + /// The copied process selected after the attach. + /// is not positive. + /// The activation has ended. + /// + /// The activation is stopping, or the attach failed with . + /// + /// + /// The attach observed the cancellation of . + /// + /// The attach failed with any other failure kind. public ProcessSnapshot Attach(TargetProcessId processId, CancellationToken cancellationToken = default); - /// Tries to attach to the single locally discovered process whose executable name matches exactly. + /// + /// Tries to attach to the single locally discovered process whose executable name matches exactly. + /// + /// The executable file name, with or without .exe and without a directory. + /// The copied process selected after the attach on success; otherwise the default. + /// + /// The classified failure, when no local process matches and + /// when several do; the default value on success. + /// + /// + /// Observed before the local catalog is searched, and before the attach is dispatched to Cheat Engine's main + /// thread. + /// + /// when Cheat Engine selected the single match and it was observed. /// /// Activation admission occurs before local discovery; caller cancellation is then observed before catalog access. /// A local match is only an attach candidate. Cheat Engine's selected target is verified before returning. /// + /// is . + /// + /// is empty, white space, includes a directory path or is only .exe. + /// + /// The activation has ended. + /// The activation is stopping. public bool TryAttachExactName(string processName, out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Attaches to the single exact-name match or throws when none or several exist. + /// The executable file name, with or without .exe and without a directory. + /// + /// Observed before the local catalog is searched, and before the attach is dispatched to Cheat Engine's main + /// thread. + /// + /// The copied process selected after the attach. + /// is . + /// + /// is empty, white space, includes a directory path or is only .exe. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the attach failed with . + /// + /// + /// The attach observed the cancellation of . + /// + /// + /// The attach failed with any other failure kind, and + /// included. + /// public ProcessSnapshot AttachExactName(string processName, CancellationToken cancellationToken = default); - /// Tries to attach Cheat Engine to the foreground process when the host exposes a validated binding. - public bool TryAttachForeground(out ProcessSnapshot snapshot, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Attaches Cheat Engine to the foreground process when the host exposes a validated binding. - public ProcessSnapshot AttachForeground(CancellationToken cancellationToken = default); - - /// - /// Tries to create and select an explicit process when the host exposes a validated create-and-attach binding. - /// - public bool TryCreate(ProcessStartRequest request, out ProcessSnapshot snapshot, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Creates and selects an explicit process when the host exposes a validated create-and-attach binding. - public ProcessSnapshot Create(ProcessStartRequest request, CancellationToken cancellationToken = default); - - /// Tries to pause the selected target when the host exposes a validated pause binding. - public bool TryPause(out ProcessSnapshot snapshot, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Pauses the selected target when the host exposes a validated pause binding. - public ProcessSnapshot Pause(CancellationToken cancellationToken = default); - - /// Tries to resume the selected target when the host exposes a validated resume binding. - public bool TryResumeExecution(out ProcessSnapshot snapshot, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Resumes the selected target when the host exposes a validated resume binding. - public ProcessSnapshot ResumeExecution(CancellationToken cancellationToken = default); + /// Tries to enumerate copied local-process metadata within an explicit materialization bound. + /// The bound on the number of copied processes and an optional name filter. + /// The copied processes, ordered by identifier, on success; otherwise the default. + /// The classified failure; the default value on success. + /// + /// Observed before the catalog is read and between the Client's materialization steps. + /// + /// when the local catalog was read. + /// + /// This is an offline diagnostic of the local operating-system process catalog. It neither dispatches to Cheat + /// Engine nor observes, selects or proves a Cheat Engine target, needs no current activation, and its values + /// remain ordinary managed snapshots after the plugin is disabled. The catalog never describes a CEServer target + /// or a file opened as a process, and a local identifier equal to a Cheat Engine target identifier is not evidence + /// that both name the same process. Request validation occurs first; cancellation is then observed before catalog + /// access and between Client-managed materialization steps and reported as + /// with . A + /// catalog that cannot be read is . + /// + /// + /// is the request, whose bound is zero + /// (), or a tampered one with an empty name filter. + /// + public bool TryGetLocalProcesses(LocalProcessEnumerationRequest request, out LocalProcessEnumerationResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Tries to observe the selected target's pause state when the host exposes a validated binding. - public bool TryGetPauseState(out ProcessPauseSnapshot snapshot, out CheatEngineFailure failure, + /// Enumerates copied local-process metadata within an explicit materialization bound. + /// The bound on the number of copied processes and an optional name filter. + /// + /// Observed before the catalog is read and between the Client's materialization steps. + /// + /// The copied processes, ordered by identifier. + /// Needs no activation, like . + /// + /// is the request, whose bound is zero + /// (), or a tampered one with an empty name filter. + /// + /// + /// The enumeration observed the cancellation of . + /// + /// + /// The enumeration failed with any other failure kind: the local catalog could not be read. + /// + public LocalProcessEnumerationResult GetLocalProcesses(LocalProcessEnumerationRequest request, CancellationToken cancellationToken = default); - - /// Observes the selected target's pause state when the host exposes a validated binding. - public ProcessPauseSnapshot GetPauseState(CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessEnumerationRequest.cs b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessEnumerationRequest.cs new file mode 100644 index 0000000..a66f7a3 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessEnumerationRequest.cs @@ -0,0 +1,41 @@ +namespace CheatEngine.Client.Processes; + +/// Defines a bounded, copied local-process enumeration. +public readonly record struct LocalProcessEnumerationRequest +{ + /// Creates a bounded process enumeration request. + /// The positive maximum number of processes to copy. + /// + /// A case-insensitive substring the process name must contain, or for every process. + /// + /// + /// is zero or negative. + /// + /// is empty. + public LocalProcessEnumerationRequest(int maximumResults, string? nameContains = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumResults); + if (nameContains is { Length: 0 }) + { + throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(nameContains)); + } + + MaximumResults = maximumResults; + NameContains = nameContains; + } + + /// + /// Gets the maximum number of process records copied: a longer catalog is truncated to it and reported with + /// . + /// + public int MaximumResults + { + get; + } + + /// Gets the optional case-insensitive substring applied to process names. + public string? NameContains + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessEnumerationResult.cs b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessEnumerationResult.cs new file mode 100644 index 0000000..2f8c007 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessEnumerationResult.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Processes; + +/// A copied, bounded local-process enumeration result. +public readonly struct LocalProcessEnumerationResult +{ + private readonly ImmutableArray _processes; + + /// Creates a copied process enumeration result. + /// The copied processes, ordered by identifier; a default array is empty. + /// Whether matching processes were omitted at the caller's limit. + /// + /// is and is empty. + /// + public LocalProcessEnumerationResult(ImmutableArray processes, bool isTruncated) + { + _processes = processes.IsDefault ? ImmutableArray.Empty : processes; + if (isTruncated && _processes.IsEmpty) + { + throw new ArgumentException("A truncated process result must retain at least one copied process.", + nameof(processes)); + } + + IsTruncated = isTruncated; + } + + /// Gets copied local-process metadata, ordered by process identifier. + /// Empty for the value, never a default array. + public ImmutableArray Processes => + _processes.IsDefault ? ImmutableArray.Empty : _processes; + + /// Gets whether matching local processes were omitted at the caller's explicit limit. + public bool IsTruncated + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs index b223378..c48a167 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessId.cs @@ -5,6 +5,8 @@ namespace CheatEngine.Client.Processes; public readonly record struct LocalProcessId { /// Creates a positive local operating-system process identifier. + /// The positive process identifier. + /// is zero or negative. public LocalProcessId(int value) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessSnapshot.cs similarity index 65% rename from libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs rename to libs/CheatEngine.Client.Abstractions/Processes/LocalProcessSnapshot.cs index a56591b..9da1a20 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/LocalProcessSnapshot.cs @@ -2,10 +2,16 @@ namespace CheatEngine.Client.Processes; /// Copied local-process metadata that is independent of Cheat Engine's selected target. /// This data is local operating-system enrichment only; it neither selects nor identifies a Cheat Engine target. -public readonly record struct ProcessInfoSnapshot +public readonly record struct LocalProcessSnapshot { /// Creates copied local-process metadata. - public ProcessInfoSnapshot(LocalProcessId id, string? name, string? executablePath) + /// The local operating-system process identifier. + /// The display name, or when it could not be read. + /// The executable path, or when it could not be read. + /// + /// or is empty. + /// + public LocalProcessSnapshot(LocalProcessId id, string? name, string? executablePath) { if (name is { Length: 0 }) { diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs deleted file mode 100644 index 189c08e..0000000 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace CheatEngine.Client.Processes; - -/// Defines a bounded, copied local-process enumeration. -public readonly record struct ProcessEnumerationRequest -{ - /// Creates a bounded process enumeration request. - public ProcessEnumerationRequest(int maximumItems, string? nameContains = null) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); - if (nameContains is { Length: 0 }) - { - throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(nameContains)); - } - - MaximumItems = maximumItems; - NameContains = nameContains; - } - - /// Gets the maximum number of copied process records the caller permits. - public int MaximumItems - { - get; - } - - /// Gets the optional case-insensitive substring applied to process names. - public string? NameContains - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs deleted file mode 100644 index 5dc7a5f..0000000 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Immutable; - -namespace CheatEngine.Client.Processes; - -/// A copied, bounded local-process enumeration result. -public readonly record struct ProcessEnumerationResult -{ - /// Creates a copied process enumeration result. - public ProcessEnumerationResult(ImmutableArray processes, bool isTruncated) - { - Processes = processes.IsDefault ? ImmutableArray.Empty : processes; - if (isTruncated && Processes.IsEmpty) - { - throw new ArgumentException("A truncated process result must retain at least one copied process.", - nameof(processes)); - } - - IsTruncated = isTruncated; - } - - /// Gets copied local-process metadata, ordered by process identifier. - public ImmutableArray Processes - { - get; - } - - /// Gets whether matching local processes were omitted at the caller's explicit limit. - public bool IsTruncated - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs deleted file mode 100644 index a039637..0000000 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CheatEngine.SDK.Engine.Inspection; - -namespace CheatEngine.Client.Processes; - -/// A copied pause-state observation tied to one selected-target epoch. -public readonly record struct ProcessPauseSnapshot -{ - /// Creates a target pause-state observation. - public ProcessPauseSnapshot(TargetProcessId processId, ProcessPauseState state, long selectionEpoch) - { - ArgumentOutOfRangeException.ThrowIfNegative(selectionEpoch); - ProcessId = processId; - State = state; - SelectionEpoch = selectionEpoch; - } - - /// Gets the selected process identifier observed with this state. - public TargetProcessId ProcessId - { - get; - } - - /// Gets the observed pause state. - public ProcessPauseState State - { - get; - } - - /// Gets the target-selection epoch associated with the observation. - public long SelectionEpoch - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs deleted file mode 100644 index ca611fc..0000000 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CheatEngine.Client.Processes; - -/// Describes whether a selected process is paused. -public enum ProcessPauseState -{ - /// The host could not establish the state. - Unknown = 0, - - /// The target is executing. - Running = 1, - - /// The target is paused. - Paused = 2 -} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs index 39e287d..8ab3b3a 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs @@ -5,23 +5,60 @@ namespace CheatEngine.Client.Processes; /// An immutable snapshot of the process currently selected in Cheat Engine. /// -/// The identifier and architecture are Cheat Engine observations. Name and executable path are optional local BCL -/// enrichment and do not establish liveness or authoritative target provenance. +/// +/// The identifier, the backend, the architecture, the bitness and the configured pointer size are Cheat Engine +/// observations that CheatEngine.SDK reported. The start time is the creation time CheatEngine.SDK observed for +/// a local process together with the local backend; with the identifier it is the process incarnation. Name and +/// executable path are optional local operating-system enrichment. Start time, name and path describe a local +/// process only: a CEServer target, a file opened as a process or a target whose backend is not established +/// never has them, and they do not establish liveness. +/// +/// +/// Every value is stored as observed; none is derived from another. In particular the bitness is never derived +/// from the architecture or from the plugin's own process width, and the configured pointer size is never +/// taken for the bitness. +/// +/// +/// Cheat Engine's selected target is ambient: holding this snapshot does not stop the user, another plugin or a +/// script from selecting another process. reduces that risk for Client-owned +/// leases; it is not a transaction. +/// /// public readonly record struct ProcessSnapshot { - /// Creates a selected-process snapshot without a target-architecture observation. - public ProcessSnapshot(TargetProcessId id, string? name, string? executablePath) - : this(id, name, executablePath, CheatEngineArchitecture.Unknown, 0) - { - } - /// Creates a selected-process snapshot from copied host observations. + /// The Cheat Engine selected process identifier. + /// The local process name, when the local catalog supplied one for a local process. + /// The local executable path, when the local catalog supplied one for a local process. + /// How Cheat Engine reaches the target, or unknown. + /// The ISA CheatEngine.SDK derived from Cheat Engine's family facts, or unknown. + /// The target bitness (targetIs64Bit), or unknown. + /// + /// The raw value of Cheat Engine's configured pointer size for the current attachment, or + /// when it was not observed. Any integer is kept. + /// + /// + /// The creation time CheatEngine.SDK observed for a local process, in UTC, or . + /// + /// The target-selection epoch of the observation. + /// + /// or is empty; a known + /// and a known disagree; + /// is not a UTC time; or local metadata or a start time is supplied for a + /// backend other than . + /// + /// + /// is not a defined value, or is negative. + /// public ProcessSnapshot( TargetProcessId id, string? name, string? executablePath, - CheatEngineArchitecture targetArchitecture, + TargetBackend backend, + CheatEngineArchitecture architecture, + PointerSize bitness, + int? configuredPointerSizeBytes, + DateTimeOffset? startTimeUtc, long selectionEpoch) { if (name is { Length: 0 }) @@ -34,41 +71,122 @@ public ProcessSnapshot( throw new ArgumentException("An executable path must be null or non-empty.", nameof(executablePath)); } + if (!Enum.IsDefined(backend)) + { + throw new ArgumentOutOfRangeException(nameof(backend), backend, "The target backend is not defined."); + } + + if (backend != TargetBackend.LocalProcess && (name is not null || executablePath is not null || + startTimeUtc is not null)) + { + throw new ArgumentException( + "Local process metadata and a start time describe a local process only, never another backend.", + nameof(backend)); + } + + if (GetNaturalWidth(architecture) is { IsKnown: true } naturalWidth && bitness.IsKnown && + bitness.Bytes != naturalWidth.Bytes) + { + throw new ArgumentException("A known bitness must match the natural width of a known architecture.", + nameof(bitness)); + } + + if (startTimeUtc is { Offset: var offset } && offset != TimeSpan.Zero) + { + throw new ArgumentException("A process start time must be expressed in UTC.", nameof(startTimeUtc)); + } + ArgumentOutOfRangeException.ThrowIfNegative(selectionEpoch); Id = id; Name = name; ExecutablePath = executablePath; - TargetArchitecture = targetArchitecture; + Backend = backend; + Architecture = architecture; + Bitness = bitness; + ConfiguredPointerSizeBytes = configuredPointerSizeBytes; + StartTimeUtc = startTimeUtc; SelectionEpoch = selectionEpoch; } - /// Gets the selected process identifier. + /// Gets the selected process identifier, as observed from Cheat Engine. public TargetProcessId Id { get; } - /// Gets the process display name when the host supplied one. + /// Gets the local process display name when the local catalog supplied one for a local process. public string? Name { get; } - /// Gets the executable path when the host supplied one. + /// Gets the local executable path when the local catalog supplied one for a local process. public string? ExecutablePath { get; } - /// Gets the target architecture observed by Cheat Engine, or unknown when no probe established it. - public CheatEngineArchitecture TargetArchitecture + /// + /// Gets how Cheat Engine reaches the target: a local process, CEServer, or unknown when the backend fact was not + /// established. + /// + public TargetBackend Backend { get; } - /// Gets the pointer width implied by , or unknown. - public PointerSize TargetPointerSize => PointerSize.FromArchitecture(TargetArchitecture); + /// + /// Gets the ISA CheatEngine.SDK derived from Cheat Engine's targetIsX86/targetIsArm and + /// targetIs64Bit facts, or unknown; never derived from the bitness alone. + /// + public CheatEngineArchitecture Architecture + { + get; + } + + /// + /// Gets the target bitness (targetIs64Bit, the width Cheat Engine's readPointer follows), or + /// unknown. It is stored as observed and is not Cheat Engine's configured pointer size. + /// + public PointerSize Bitness + { + get; + } + + /// + /// Gets the raw value of Cheat Engine's configured pointer size (getPointerSize) for the current + /// attachment, or when it was not observed. + /// + /// Any (re)attach resets it; it can hold a value other than 4 or 8. + public int? ConfiguredPointerSizeBytes + { + get; + } + + /// Gets the configured pointer size as a width when it is 4 or 8 bytes; otherwise unknown. + public PointerSize ConfiguredPointerSize => ConfiguredPointerSizeBytes switch + { + sizeof(uint) => PointerSize.Bit32, + sizeof(ulong) => PointerSize.Bit64, + _ => PointerSize.Unknown + }; + + /// + /// Gets whether the configured pointer size differs from , or when + /// either value is unknown. + /// + public bool? ConfiguredPointerSizeDiffersFromBitness => + ConfiguredPointerSizeBytes is { } configured && Bitness.IsKnown ? configured != Bitness.Bytes : null; + + /// + /// Gets the creation time CheatEngine.SDK observed for a local process, in UTC, or . With + /// it identifies the process incarnation. + /// + public DateTimeOffset? StartTimeUtc + { + get; + } /// /// Gets the target-selection epoch. A change means target-bound sessions and leases captured for an earlier @@ -78,4 +196,14 @@ public long SelectionEpoch { get; } + + private static PointerSize GetNaturalWidth(CheatEngineArchitecture architecture) + { + return architecture switch + { + CheatEngineArchitecture.X86 or CheatEngineArchitecture.Arm32 => PointerSize.Bit32, + CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64 => PointerSize.Bit64, + _ => PointerSize.Unknown + }; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs deleted file mode 100644 index 3dd5356..0000000 --- a/libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace CheatEngine.Client.Processes; - -/// Describes an explicit executable launch that must also become the selected Cheat Engine target. -public readonly record struct ProcessStartRequest -{ - /// Creates an explicit process-launch request. - public ProcessStartRequest(string executablePath, string? arguments = null, string? workingDirectory = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); - if (!Path.IsPathFullyQualified(executablePath)) - { - throw new ArgumentException("The executable path must be absolute.", nameof(executablePath)); - } - - if (workingDirectory is { } directory && !Path.IsPathFullyQualified(directory)) - { - throw new ArgumentException("The working directory must be absolute when specified.", - nameof(workingDirectory)); - } - - ExecutablePath = executablePath; - Arguments = arguments; - WorkingDirectory = workingDirectory; - } - - /// Gets the absolute executable path. - public string ExecutablePath - { - get; - } - - /// Gets the optional command-line arguments. - public string? Arguments - { - get; - } - - /// Gets the optional absolute working directory. - public string? WorkingDirectory - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt index f755b96..7dc5c58 100644 --- a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt @@ -1,680 +1 @@ #nullable enable -~override CheatEngine.Client.Inspection.InspectionCollectionRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Inspection.InspectionCollectionRequest.ToString() -> string -~override CheatEngine.Client.Inspection.SymbolRegistration.Equals(object obj) -> bool -~override CheatEngine.Client.Inspection.SymbolRegistration.ToString() -> string -~override CheatEngine.Client.Lua.LuaScript.Equals(object obj) -> bool -~override CheatEngine.Client.Lua.LuaScript.ToString() -> string -~override CheatEngine.Client.Memory.MemoryBytesReadRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryBytesReadRequest.ToString() -> string -~override CheatEngine.Client.Memory.MemoryBytesWriteRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryBytesWriteRequest.ToString() -> string -~override CheatEngine.Client.Memory.MemoryReadRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryReadRequest.ToString() -> string -~override CheatEngine.Client.Memory.MemoryStringReadRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryStringReadRequest.ToString() -> string -~override CheatEngine.Client.Memory.MemoryStringWriteRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryStringWriteRequest.ToString() -> string -~override CheatEngine.Client.Memory.MemoryWriteRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryWriteRequest.ToString() -> string -~override CheatEngine.Client.Memory.PointerChainRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.PointerChainRequest.ToString() -> string -~override CheatEngine.Client.Processes.ProcessSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Processes.ProcessSnapshot.ToString() -> string -~override CheatEngine.Client.Results.CheatEngineFailure.Equals(object obj) -> bool -~override CheatEngine.Client.Results.CheatEngineFailure.ToString() -> string -~override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.Equals(object obj) -> bool -~override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.ToString() -> string -~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ToString() -> string -~override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.Equals(object obj) -> bool -~override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ToString() -> string -~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(object obj) -> bool -~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.ToString() -> string -~override CheatEngine.Client.Scanning.AobPattern.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobScanRange.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobScanRange.ToString() -> string -~override CheatEngine.Client.Scanning.AobScanRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobScanRequest.ToString() -> string -~override CheatEngine.Client.Scanning.AobScanResult.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobScanResult.ToString() -> string -~override CheatEngine.Client.Scanning.ValueScanMatch.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.ValueScanMatch.ToString() -> string -~override CheatEngine.Client.Scanning.ValueScanPage.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.ValueScanPage.ToString() -> string -~override CheatEngine.Client.Scanning.ValueScanReadRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.ValueScanReadRequest.ToString() -> string -~override CheatEngine.Client.Tables.AddressTableSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.AddressTableSnapshot.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordDefinition.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordDefinition.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordSearch.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordSearch.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordSnapshot.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.ToString() -> string -~override CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.MemoryRecordUpdate.ToString() -> string -~override CheatEngine.Client.Tables.TableLoadRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.TableLoadRequest.ToString() -> string -~override CheatEngine.Client.Tables.TableSaveRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.TableSaveRequest.ToString() -> string -~override CheatEngine.Client.Tables.TrustedTableFile.Equals(object obj) -> bool -~override CheatEngine.Client.Tables.TrustedTableFile.ToString() -> string -CheatEngine.Client.Dispatching.ICheatEngineDispatcher -CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Action! callback, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Func! callback, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T -CheatEngine.Client.Dispatching.ICheatEngineDispatcher.IsMainThread.get -> bool -CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Action! callback, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Func! callback, out T result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.ICheatEngineClient -CheatEngine.Client.ICheatEngineClient.Dispatcher.get -> CheatEngine.Client.Dispatching.ICheatEngineDispatcher! -CheatEngine.Client.ICheatEngineClient.Epoch.get -> long -CheatEngine.Client.ICheatEngineClient.Inspection.get -> CheatEngine.Client.Inspection.IInspectionClient! -CheatEngine.Client.ICheatEngineClient.Lua.get -> CheatEngine.Client.Lua.ILuaClient! -CheatEngine.Client.ICheatEngineClient.Memory.get -> CheatEngine.Client.Memory.IMemoryClient! -CheatEngine.Client.ICheatEngineClient.Patterns.get -> CheatEngine.Client.Scanning.IPatternScanner! -CheatEngine.Client.ICheatEngineClient.Processes.get -> CheatEngine.Client.Processes.IProcessClient! -CheatEngine.Client.ICheatEngineClient.Runtime.get -> CheatEngine.Client.Runtime.ICheatEngineRuntime! -CheatEngine.Client.ICheatEngineClient.Scans.get -> CheatEngine.Client.Scanning.IValueScanner! -CheatEngine.Client.ICheatEngineClient.Stopping.get -> System.Threading.CancellationToken -CheatEngine.Client.ICheatEngineClient.Tables.get -> CheatEngine.Client.Tables.ITableClient! -CheatEngine.Client.Inspection.IInspectionClient -CheatEngine.Client.Inspection.IInspectionClient.GetMemoryRegion(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo -CheatEngine.Client.Inspection.IInspectionClient.GetMemoryRegions(CheatEngine.Client.Inspection.InspectionCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Inspection.IInspectionClient.GetModules(CheatEngine.Client.Inspection.InspectionCollectionRequest request, CheatEngine.SDK.Engine.Inspection.TargetProcessId? processId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Inspection.IInspectionClient.GetModuleSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, CheatEngine.Client.Inspection.InspectionCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Inspection.IInspectionClient.GetSymbol(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Inspection.SymbolInfo -CheatEngine.Client.Inspection.IInspectionClient.RegisterSymbol(CheatEngine.Client.Inspection.SymbolRegistration registration, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Inspection.ISymbolRegistrationLease! -CheatEngine.Client.Inspection.IInspectionClient.ResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Inspection.IInspectionClient.ResolveName(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! -CheatEngine.Client.Inspection.IInspectionClient.TryGetMemoryRegion(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo region, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryGetMemoryRegions(CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray regions, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryGetModules(CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray modules, out CheatEngine.Client.Results.CheatEngineFailure failure, CheatEngine.SDK.Engine.Inspection.TargetProcessId? processId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryGetModuleSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray sections, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryGetSymbol(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryRegisterSymbol(CheatEngine.Client.Inspection.SymbolRegistration registration, out CheatEngine.Client.Inspection.ISymbolRegistrationLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.IInspectionClient.TryResolveName(CheatEngine.SDK.Engine.Values.Address address, out string? name, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Inspection.InspectionCollectionRequest -CheatEngine.Client.Inspection.InspectionCollectionRequest.Equals(CheatEngine.Client.Inspection.InspectionCollectionRequest other) -> bool -CheatEngine.Client.Inspection.InspectionCollectionRequest.InspectionCollectionRequest() -> void -CheatEngine.Client.Inspection.InspectionCollectionRequest.InspectionCollectionRequest(int maximumItems) -> void -CheatEngine.Client.Inspection.InspectionCollectionRequest.MaximumItems.get -> int -CheatEngine.Client.Inspection.ISymbolRegistrationLease -CheatEngine.Client.Inspection.ISymbolRegistrationLease.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Inspection.ISymbolRegistrationLease.IsReleased.get -> bool -CheatEngine.Client.Inspection.ISymbolRegistrationLease.Name.get -> string! -CheatEngine.Client.Inspection.SymbolRegistration -CheatEngine.Client.Inspection.SymbolRegistration.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Inspection.SymbolRegistration.DoNotSave.get -> bool -CheatEngine.Client.Inspection.SymbolRegistration.Equals(CheatEngine.Client.Inspection.SymbolRegistration other) -> bool -CheatEngine.Client.Inspection.SymbolRegistration.Name.get -> string! -CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration() -> void -CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration(string! name, CheatEngine.SDK.Engine.Values.Address address, bool doNotSave = true) -> void -CheatEngine.Client.Lua.ILuaClient -CheatEngine.Client.Lua.ILuaClient.Execute(CheatEngine.Client.Lua.ILuaOperation! operation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult -CheatEngine.Client.Lua.ILuaClient.RegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Lua.ILuaModuleLease! -CheatEngine.Client.Lua.ILuaClient.TryExecute(CheatEngine.Client.Lua.ILuaOperation! operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Lua.ILuaClient.TryRegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, out CheatEngine.Client.Lua.ILuaModuleLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Lua.ILuaExecutionContext -CheatEngine.Client.Lua.ILuaExecutionContext.Epoch.get -> long -CheatEngine.Client.Lua.ILuaExecutionContext.IsActive.get -> bool -CheatEngine.Client.Lua.ILuaExecutionContext.ThrowIfExpired() -> void -CheatEngine.Client.Lua.ILuaModule -CheatEngine.Client.Lua.ILuaModule.Register() -> void -CheatEngine.Client.Lua.ILuaModule.Unregister() -> void -CheatEngine.Client.Lua.ILuaModuleLease -CheatEngine.Client.Lua.ILuaModuleLease.Epoch.get -> long -CheatEngine.Client.Lua.ILuaModuleLease.IsReleased.get -> bool -CheatEngine.Client.Lua.ILuaOperation -CheatEngine.Client.Lua.ILuaOperation.TryExecute(CheatEngine.Client.Lua.ILuaExecutionContext! context, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool -CheatEngine.Client.Lua.IUnsafeLuaClient -CheatEngine.Client.Lua.IUnsafeLuaClient.Execute(CheatEngine.Client.Lua.LuaScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Lua.IUnsafeLuaClient.TryExecute(CheatEngine.Client.Lua.LuaScript script, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Lua.LuaScript -CheatEngine.Client.Lua.LuaScript.ChunkName.get -> string? -CheatEngine.Client.Lua.LuaScript.Equals(CheatEngine.Client.Lua.LuaScript other) -> bool -CheatEngine.Client.Lua.LuaScript.LuaScript() -> void -CheatEngine.Client.Lua.LuaScript.LuaScript(string! source, string? chunkName = null) -> void -CheatEngine.Client.Lua.LuaScript.Source.get -> string! -CheatEngine.Client.Memory.IMemoryClient -CheatEngine.Client.Memory.IMemoryClient.Read(CheatEngine.Client.Memory.MemoryReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T -CheatEngine.Client.Memory.IMemoryClient.ReadBytes(CheatEngine.Client.Memory.MemoryBytesReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Memory.IMemoryClient.ReadPrimitive(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T -CheatEngine.Client.Memory.IMemoryClient.ReadString(CheatEngine.Client.Memory.MemoryStringReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! -CheatEngine.Client.Memory.IMemoryClient.ResolvePointerChain(CheatEngine.Client.Memory.PointerChainRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.IMemoryClient.TryRead(CheatEngine.Client.Memory.MemoryReadRequest request, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryReadBytes(CheatEngine.Client.Memory.MemoryBytesReadRequest request, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryReadPrimitive(CheatEngine.SDK.Engine.Values.Address address, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryReadString(CheatEngine.Client.Memory.MemoryStringReadRequest request, out string? value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryResolvePointerChain(CheatEngine.Client.Memory.PointerChainRequest request, out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryWrite(CheatEngine.Client.Memory.MemoryWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryWriteBytes(CheatEngine.Client.Memory.MemoryBytesWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryWritePrimitive(CheatEngine.SDK.Engine.Values.Address address, T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryWriteString(CheatEngine.Client.Memory.MemoryStringWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.Write(CheatEngine.Client.Memory.MemoryWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.IMemoryClient.WriteBytes(CheatEngine.Client.Memory.MemoryBytesWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.IMemoryClient.WritePrimitive(CheatEngine.SDK.Engine.Values.Address address, T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.IMemoryClient.WriteString(CheatEngine.Client.Memory.MemoryStringWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.IMemoryCodec -CheatEngine.Client.Memory.IMemoryCodec.TryRead(CheatEngine.Client.Memory.IMemoryReadContext! context, CheatEngine.SDK.Engine.Values.Address address, out T value) -> bool -CheatEngine.Client.Memory.IMemoryCodec.TryWrite(CheatEngine.Client.Memory.IMemoryWriteContext! context, CheatEngine.SDK.Engine.Values.Address address, in T value) -> bool -CheatEngine.Client.Memory.IMemoryReadContext -CheatEngine.Client.Memory.IMemoryReadContext.PointerSize.get -> int -CheatEngine.Client.Memory.IMemoryReadContext.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination) -> bool -CheatEngine.Client.Memory.IMemoryWriteContext -CheatEngine.Client.Memory.IMemoryWriteContext.PointerSize.get -> int -CheatEngine.Client.Memory.IMemoryWriteContext.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan source) -> bool -CheatEngine.Client.Memory.MemoryBytesReadRequest -CheatEngine.Client.Memory.MemoryBytesReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryBytesReadRequest.Equals(CheatEngine.Client.Memory.MemoryBytesReadRequest other) -> bool -CheatEngine.Client.Memory.MemoryBytesReadRequest.Length.get -> int -CheatEngine.Client.Memory.MemoryBytesReadRequest.MemoryBytesReadRequest() -> void -CheatEngine.Client.Memory.MemoryBytesReadRequest.MemoryBytesReadRequest(CheatEngine.SDK.Engine.Values.Address address, int length) -> void -CheatEngine.Client.Memory.MemoryBytesWriteRequest -CheatEngine.Client.Memory.MemoryBytesWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryBytesWriteRequest.Bytes.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Memory.MemoryBytesWriteRequest.Equals(CheatEngine.Client.Memory.MemoryBytesWriteRequest other) -> bool -CheatEngine.Client.Memory.MemoryBytesWriteRequest.MemoryBytesWriteRequest() -> void -CheatEngine.Client.Memory.MemoryBytesWriteRequest.MemoryBytesWriteRequest(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan bytes) -> void -CheatEngine.Client.Memory.MemoryReadRequest -CheatEngine.Client.Memory.MemoryReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryReadRequest.Codec.get -> CheatEngine.Client.Memory.IMemoryCodec! -CheatEngine.Client.Memory.MemoryReadRequest.Equals(CheatEngine.Client.Memory.MemoryReadRequest other) -> bool -CheatEngine.Client.Memory.MemoryReadRequest.MemoryReadRequest() -> void -CheatEngine.Client.Memory.MemoryReadRequest.MemoryReadRequest(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.Client.Memory.IMemoryCodec! codec) -> void -CheatEngine.Client.Memory.MemoryStringReadRequest -CheatEngine.Client.Memory.MemoryStringReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryStringReadRequest.Equals(CheatEngine.Client.Memory.MemoryStringReadRequest other) -> bool -CheatEngine.Client.Memory.MemoryStringReadRequest.MaximumLength.get -> int -CheatEngine.Client.Memory.MemoryStringReadRequest.MemoryStringReadRequest() -> void -CheatEngine.Client.Memory.MemoryStringReadRequest.MemoryStringReadRequest(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, bool wideCharacter = false) -> void -CheatEngine.Client.Memory.MemoryStringReadRequest.WideCharacter.get -> bool -CheatEngine.Client.Memory.MemoryStringWriteRequest -CheatEngine.Client.Memory.MemoryStringWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryStringWriteRequest.Equals(CheatEngine.Client.Memory.MemoryStringWriteRequest other) -> bool -CheatEngine.Client.Memory.MemoryStringWriteRequest.MemoryStringWriteRequest() -> void -CheatEngine.Client.Memory.MemoryStringWriteRequest.MemoryStringWriteRequest(CheatEngine.SDK.Engine.Values.Address address, string! value, bool wideCharacter = false) -> void -CheatEngine.Client.Memory.MemoryStringWriteRequest.Value.get -> string! -CheatEngine.Client.Memory.MemoryStringWriteRequest.WideCharacter.get -> bool -CheatEngine.Client.Memory.MemoryWriteRequest -CheatEngine.Client.Memory.MemoryWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryWriteRequest.Codec.get -> CheatEngine.Client.Memory.IMemoryCodec! -CheatEngine.Client.Memory.MemoryWriteRequest.Equals(CheatEngine.Client.Memory.MemoryWriteRequest other) -> bool -CheatEngine.Client.Memory.MemoryWriteRequest.MemoryWriteRequest() -> void -CheatEngine.Client.Memory.MemoryWriteRequest.MemoryWriteRequest(CheatEngine.SDK.Engine.Values.Address address, T value, CheatEngine.Client.Memory.IMemoryCodec! codec) -> void -CheatEngine.Client.Memory.MemoryWriteRequest.Value.get -> T -CheatEngine.Client.Memory.PointerChainRequest -CheatEngine.Client.Memory.PointerChainRequest.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.PointerChainRequest.Equals(CheatEngine.Client.Memory.PointerChainRequest other) -> bool -CheatEngine.Client.Memory.PointerChainRequest.Offsets.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Memory.PointerChainRequest.PointerChainRequest() -> void -CheatEngine.Client.Memory.PointerChainRequest.PointerChainRequest(CheatEngine.SDK.Engine.Values.Address baseAddress, System.ReadOnlySpan offsets) -> void -CheatEngine.Client.Modules.ICheatEngineClientModule -CheatEngine.Client.Modules.ICheatEngineClientModule.OnDisabling(CheatEngine.Client.ICheatEngineClient! client) -> void -CheatEngine.Client.Modules.ICheatEngineClientModule.OnEnabled(CheatEngine.Client.ICheatEngineClient! client) -> void -CheatEngine.Client.Processes.IProcessClient -CheatEngine.Client.Processes.IProcessClient.Attach(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.AttachExactName(string! processName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.GetCurrent(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.Refresh(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.TryAttach(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryAttachExactName(string! processName, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryGetCurrent(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryRefresh(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.ProcessSnapshot.Equals(CheatEngine.Client.Processes.ProcessSnapshot other) -> bool -CheatEngine.Client.Processes.ProcessSnapshot.ExecutablePath.get -> string? -CheatEngine.Client.Processes.ProcessSnapshot.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId -CheatEngine.Client.Processes.ProcessSnapshot.Name.get -> string? -CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot() -> void -CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, long selectionEpoch) -> void -CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath) -> void -CheatEngine.Client.Processes.ProcessSnapshot.SelectionEpoch.get -> long -CheatEngine.Client.Processes.ProcessSnapshot.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture -CheatEngine.Client.Processes.ProcessSnapshot.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize -CheatEngine.Client.Results.CheatEngineActivationExpiredException -CheatEngine.Client.Results.CheatEngineActivationExpiredException.CheatEngineActivationExpiredException(string! operation, string! message, System.Exception? innerException = null) -> void -CheatEngine.Client.Results.CheatEngineClientException -CheatEngine.Client.Results.CheatEngineClientException.CheatEngineClientException(CheatEngine.Client.Results.CheatEngineFailure failure) -> void -CheatEngine.Client.Results.CheatEngineClientException.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure -CheatEngine.Client.Results.CheatEngineClientLifecycleException -CheatEngine.Client.Results.CheatEngineClientLifecycleException.CheatEngineClientLifecycleException(string! operation, string! message, System.Exception? innerException = null) -> void -CheatEngine.Client.Results.CheatEngineFailure -CheatEngine.Client.Results.CheatEngineFailure.CheatEngineFailure() -> void -CheatEngine.Client.Results.CheatEngineFailure.CheatEngineFailure(CheatEngine.Client.Results.CheatEngineFailureKind kind, string! operation, string! message, System.Exception? exception = null) -> void -CheatEngine.Client.Results.CheatEngineFailure.Equals(CheatEngine.Client.Results.CheatEngineFailure other) -> bool -CheatEngine.Client.Results.CheatEngineFailure.Exception.get -> System.Exception? -CheatEngine.Client.Results.CheatEngineFailure.Kind.get -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailure.Message.get -> string! -CheatEngine.Client.Results.CheatEngineFailure.Operation.get -> string! -CheatEngine.Client.Results.CheatEngineFailure.Throw() -> void -CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.ActivationExpired = 14 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.AmbiguousMatch = 5 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.BindingError = 8 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.Cancelled = 1 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.CapabilityUnavailable = 2 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.InvalidHostResult = 9 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.InvalidState = 15 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.LuaError = 7 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.MemoryReadFailed = 12 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.MemoryWriteFailed = 13 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.NotFound = 4 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.OperationRejected = 3 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.ResultLimitExceeded = 6 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.TargetNotAttached = 11 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.Unknown = 0 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineFailureKind.Unsupported = 10 -> CheatEngine.Client.Results.CheatEngineFailureKind -CheatEngine.Client.Results.CheatEngineOperationException -CheatEngine.Client.Results.CheatEngineOperationException.CheatEngineOperationException(CheatEngine.Client.Results.CheatEngineFailure failure) -> void -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineRuntimePlatformInfo() -> void -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineRuntimePlatformInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture systemArchitecture, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize targetPointerSize, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi) -> void -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo other) -> bool -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture -CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot() -> void -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot(long epoch, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo versionInfo, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo platformInfo, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! sdkCapabilities, CheatEngine.Client.Runtime.ClientCapabilities! clientCapabilities) -> void -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ClientAssemblyVersion.get -> System.Version! -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ClientCapabilities.get -> CheatEngine.Client.Runtime.ClientCapabilities! -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Epoch.get -> long -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot other) -> bool -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.IsOnQualifiedCheatEngineLine.get -> bool -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ObservedCheatEngineVersion.get -> double? -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.QualifiedCheatEngineBaseline.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SdkAssemblyVersion.get -> System.Version! -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SdkCapabilities.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Platform.get -> CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Version.get -> CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineRuntimeVersionInfo() -> void -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineRuntimeVersionInfo(double? observedCheatEngineVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion qualifiedCheatEngineBaseline, System.Version! clientAssemblyVersion, System.Version! sdkAssemblyVersion) -> void -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ClientAssemblyVersion.get -> System.Version! -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo other) -> bool -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ObservedCheatEngineVersion.get -> double? -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.QualifiedCheatEngineBaseline.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion -CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.SdkAssemblyVersion.get -> System.Version! -CheatEngine.Client.Runtime.ClientCapabilities -CheatEngine.Client.Runtime.ClientCapabilities.Count.get -> int -CheatEngine.Client.Runtime.ClientCapabilities.Entries.get -> System.ReadOnlySpan -CheatEngine.Client.Runtime.ClientCapabilities.Equals(CheatEngine.Client.Runtime.ClientCapabilities? other) -> bool -CheatEngine.Client.Runtime.ClientCapabilities.TryGet(CheatEngine.Client.Runtime.ClientCapabilityId capability, out CheatEngine.Client.Runtime.ClientCapabilityAvailability availability) -> bool -CheatEngine.Client.Runtime.ClientCapabilityAvailability -CheatEngine.Client.Runtime.ClientCapabilityAvailability.Capability.get -> CheatEngine.Client.Runtime.ClientCapabilityId -CheatEngine.Client.Runtime.ClientCapabilityAvailability.ClientCapabilityAvailability() -> void -CheatEngine.Client.Runtime.ClientCapabilityAvailability.ClientCapabilityAvailability(CheatEngine.Client.Runtime.ClientCapabilityId capability, CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState state, string! reason) -> void -CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(CheatEngine.Client.Runtime.ClientCapabilityAvailability other) -> bool -CheatEngine.Client.Runtime.ClientCapabilityAvailability.IsAvailable.get -> bool -CheatEngine.Client.Runtime.ClientCapabilityAvailability.IsKnown.get -> bool -CheatEngine.Client.Runtime.ClientCapabilityAvailability.Reason.get -> string! -CheatEngine.Client.Runtime.ClientCapabilityAvailability.State.get -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState -CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState -CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Available = 1 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState -CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Unavailable = 2 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState -CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Unknown = 0 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState -CheatEngine.Client.Runtime.ClientCapabilityId -CheatEngine.Client.Runtime.ClientCapabilityId.ClientCapabilityId() -> void -CheatEngine.Client.Runtime.ClientCapabilityId.ClientCapabilityId(string! value) -> void -CheatEngine.Client.Runtime.ClientCapabilityId.Equals(CheatEngine.Client.Runtime.ClientCapabilityId other) -> bool -CheatEngine.Client.Runtime.ClientCapabilityId.IsEmpty.get -> bool -CheatEngine.Client.Runtime.ClientCapabilityId.Value.get -> string! -CheatEngine.Client.Runtime.ICheatEngineRuntime -CheatEngine.Client.Runtime.ICheatEngineRuntime.Epoch.get -> long -CheatEngine.Client.Runtime.ICheatEngineRuntime.GetClientCapability(CheatEngine.Client.Runtime.ClientCapabilityId capability, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Runtime.ClientCapabilityAvailability -CheatEngine.Client.Runtime.ICheatEngineRuntime.GetSdkCapability(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability -CheatEngine.Client.Runtime.ICheatEngineRuntime.GetSnapshot(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot -CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetClientCapability(CheatEngine.Client.Runtime.ClientCapabilityId capability, out CheatEngine.Client.Runtime.ClientCapabilityAvailability availability, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetSdkCapability(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability availability, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetSnapshot(out CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.AobPattern -CheatEngine.Client.Scanning.AobPattern.AobPattern() -> void -CheatEngine.Client.Scanning.AobPattern.AobPattern(string! value) -> void -CheatEngine.Client.Scanning.AobPattern.ByteLength.get -> int -CheatEngine.Client.Scanning.AobPattern.Equals(CheatEngine.Client.Scanning.AobPattern other) -> bool -CheatEngine.Client.Scanning.AobPattern.IsWildcardOnly.get -> bool -CheatEngine.Client.Scanning.AobPattern.Value.get -> string! -CheatEngine.Client.Scanning.AobScanRange -CheatEngine.Client.Scanning.AobScanRange.AobScanRange() -> void -CheatEngine.Client.Scanning.AobScanRange.AobScanRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> void -CheatEngine.Client.Scanning.AobScanRange.Contains(CheatEngine.SDK.Engine.Values.Address address) -> bool -CheatEngine.Client.Scanning.AobScanRange.End.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Scanning.AobScanRange.Equals(CheatEngine.Client.Scanning.AobScanRange other) -> bool -CheatEngine.Client.Scanning.AobScanRange.Start.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Scanning.AobScanRequest -CheatEngine.Client.Scanning.AobScanRequest.AobScanRequest() -> void -CheatEngine.Client.Scanning.AobScanRequest.AobScanRequest(CheatEngine.Client.Scanning.AobPattern pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, int maximumResults, CheatEngine.SDK.Engine.Inspection.ModuleName? module = null, CheatEngine.Client.Scanning.AobScanRange? range = null) -> void -CheatEngine.Client.Scanning.AobScanRequest.Equals(CheatEngine.Client.Scanning.AobScanRequest other) -> bool -CheatEngine.Client.Scanning.AobScanRequest.MaximumResults.get -> int -CheatEngine.Client.Scanning.AobScanRequest.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName? -CheatEngine.Client.Scanning.AobScanRequest.Options.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions -CheatEngine.Client.Scanning.AobScanRequest.Pattern.get -> CheatEngine.Client.Scanning.AobPattern -CheatEngine.Client.Scanning.AobScanRequest.Range.get -> CheatEngine.Client.Scanning.AobScanRange? -CheatEngine.Client.Scanning.AobScanResult -CheatEngine.Client.Scanning.AobScanResult.AobScanResult() -> void -CheatEngine.Client.Scanning.AobScanResult.AobScanResult(System.Collections.Immutable.ImmutableArray matches, bool isTruncated) -> void -CheatEngine.Client.Scanning.AobScanResult.Equals(CheatEngine.Client.Scanning.AobScanResult other) -> bool -CheatEngine.Client.Scanning.AobScanResult.IsTruncated.get -> bool -CheatEngine.Client.Scanning.AobScanResult.Matches.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Scanning.IPatternScanner -CheatEngine.Client.Scanning.IPatternScanner.Scan(CheatEngine.Client.Scanning.AobScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult -CheatEngine.Client.Scanning.IPatternScanner.TryScan(CheatEngine.Client.Scanning.AobScanRequest request, out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.IValueScanner -CheatEngine.Client.Scanning.IValueScanner.CreateSession(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.IValueScanSession! -CheatEngine.Client.Scanning.IValueScanner.TryCreateSession(out CheatEngine.Client.Scanning.IValueScanSession? session, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.IValueScanSession -CheatEngine.Client.Scanning.IValueScanSession.GetResultCount(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ulong -CheatEngine.Client.Scanning.IValueScanSession.Read(CheatEngine.Client.Scanning.ValueScanReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.ValueScanPage -CheatEngine.Client.Scanning.IValueScanSession.Reset(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Scanning.IValueScanSession.RunNextScan(CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Scanning.IValueScanSession.Start(CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Scanning.IValueScanSession.State.get -> CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Scanning.IValueScanSession.TryGetResultCount(out ulong resultCount, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.IValueScanSession.TryRead(CheatEngine.Client.Scanning.ValueScanReadRequest request, out CheatEngine.Client.Scanning.ValueScanPage page, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.IValueScanSession.TryReset(out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.IValueScanSession.TryRunNextScan(CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.IValueScanSession.TryStart(CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.ValueScanMatch -CheatEngine.Client.Scanning.ValueScanMatch.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Scanning.ValueScanMatch.Equals(CheatEngine.Client.Scanning.ValueScanMatch other) -> bool -CheatEngine.Client.Scanning.ValueScanMatch.Index.get -> int -CheatEngine.Client.Scanning.ValueScanMatch.Value.get -> string! -CheatEngine.Client.Scanning.ValueScanMatch.ValueScanMatch() -> void -CheatEngine.Client.Scanning.ValueScanMatch.ValueScanMatch(int index, CheatEngine.SDK.Engine.Values.Address address, string! value) -> void -CheatEngine.Client.Scanning.ValueScanPage -CheatEngine.Client.Scanning.ValueScanPage.Equals(CheatEngine.Client.Scanning.ValueScanPage other) -> bool -CheatEngine.Client.Scanning.ValueScanPage.Matches.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Scanning.ValueScanPage.TotalCount.get -> ulong -CheatEngine.Client.Scanning.ValueScanPage.ValueScanPage() -> void -CheatEngine.Client.Scanning.ValueScanPage.ValueScanPage(ulong totalCount, System.Collections.Immutable.ImmutableArray matches) -> void -CheatEngine.Client.Scanning.ValueScanReadRequest -CheatEngine.Client.Scanning.ValueScanReadRequest.Equals(CheatEngine.Client.Scanning.ValueScanReadRequest other) -> bool -CheatEngine.Client.Scanning.ValueScanReadRequest.MaximumCount.get -> int -CheatEngine.Client.Scanning.ValueScanReadRequest.StartIndex.get -> int -CheatEngine.Client.Scanning.ValueScanReadRequest.ValueScanReadRequest() -> void -CheatEngine.Client.Scanning.ValueScanReadRequest.ValueScanReadRequest(int startIndex, int maximumCount) -> void -CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Scanning.ValueScanSessionState.Created = 0 -> CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Scanning.ValueScanSessionState.Disposed = 4 -> CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Scanning.ValueScanSessionState.Invalidated = 3 -> CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Scanning.ValueScanSessionState.ResultsReady = 2 -> CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Scanning.ValueScanSessionState.Scanning = 1 -> CheatEngine.Client.Scanning.ValueScanSessionState -CheatEngine.Client.Tables.AddressTableSnapshot -CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot() -> void -CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot(int recordCount) -> void -CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot(System.Collections.Immutable.ImmutableArray records) -> void -CheatEngine.Client.Tables.AddressTableSnapshot.Equals(CheatEngine.Client.Tables.AddressTableSnapshot other) -> bool -CheatEngine.Client.Tables.AddressTableSnapshot.RecordCount.get -> int -CheatEngine.Client.Tables.AddressTableSnapshot.Records.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Tables.ITableClient -CheatEngine.Client.Tables.ITableClient.Create(CheatEngine.Client.Tables.MemoryRecordDefinition definition, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.Delete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Tables.ITableClient.Find(CheatEngine.Client.Tables.MemoryRecordSearch search, CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Tables.ITableClient.GetCurrent(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.AddressTableSnapshot -CheatEngine.Client.Tables.ITableClient.GetHierarchy(CheatEngine.SDK.Engine.AddressList.MemoryRecordId rootId, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot -CheatEngine.Client.Tables.ITableClient.GetRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.GetRecord(int index, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.GetSelected(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.GetSnapshot(CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.AddressTableSnapshot -CheatEngine.Client.Tables.ITableClient.LoadTrustedTable(CheatEngine.Client.Tables.TableLoadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Tables.ITableClient.SaveTable(CheatEngine.Client.Tables.TableSaveRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Tables.ITableClient.SelectRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.SetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, bool isActive, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId childId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.ITableClient.TryCreate(CheatEngine.Client.Tables.MemoryRecordDefinition definition, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryDelete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryFind(CheatEngine.Client.Tables.MemoryRecordSearch search, CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, out System.Collections.Immutable.ImmutableArray records, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryGetCurrent(out CheatEngine.Client.Tables.AddressTableSnapshot table, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryGetHierarchy(CheatEngine.SDK.Engine.AddressList.MemoryRecordId rootId, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest request, out CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot hierarchy, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryGetRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryGetRecord(int index, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryGetSelected(out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryGetSnapshot(CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, out CheatEngine.Client.Tables.AddressTableSnapshot table, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryLoadTrustedTable(CheatEngine.Client.Tables.TableLoadRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TrySaveTable(CheatEngine.Client.Tables.TableSaveRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TrySelect(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TrySetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, bool isActive, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TrySetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId childId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.TryUpdate(CheatEngine.Client.Tables.MemoryRecordUpdate update, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Tables.ITableClient.Update(CheatEngine.Client.Tables.MemoryRecordUpdate update, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.MemoryRecordCollectionRequest -CheatEngine.Client.Tables.MemoryRecordCollectionRequest.Equals(CheatEngine.Client.Tables.MemoryRecordCollectionRequest other) -> bool -CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MaximumItems.get -> int -CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MemoryRecordCollectionRequest() -> void -CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MemoryRecordCollectionRequest(int maximumItems) -> void -CheatEngine.Client.Tables.MemoryRecordDefinition -CheatEngine.Client.Tables.MemoryRecordDefinition.AddressExpression.get -> string! -CheatEngine.Client.Tables.MemoryRecordDefinition.Description.get -> string! -CheatEngine.Client.Tables.MemoryRecordDefinition.Equals(CheatEngine.Client.Tables.MemoryRecordDefinition other) -> bool -CheatEngine.Client.Tables.MemoryRecordDefinition.MemoryRecordDefinition() -> void -CheatEngine.Client.Tables.MemoryRecordDefinition.MemoryRecordDefinition(string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId = null) -> void -CheatEngine.Client.Tables.MemoryRecordDefinition.ParentId.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId? -CheatEngine.Client.Tables.MemoryRecordDefinition.Value.get -> string! -CheatEngine.Client.Tables.MemoryRecordDefinition.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType -CheatEngine.Client.Tables.MemoryRecordHierarchyRequest -CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.Equals(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest other) -> bool -CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MaximumDepth.get -> int -CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MaximumItems.get -> int -CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MemoryRecordHierarchyRequest() -> void -CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MemoryRecordHierarchyRequest(int maximumItems, int maximumDepth) -> void -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Children.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Children.init -> void -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Deconstruct(out CheatEngine.Client.Tables.MemoryRecordSnapshot Record, out System.Collections.Immutable.ImmutableArray Children) -> void -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot other) -> bool -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.MemoryRecordHierarchySnapshot() -> void -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.MemoryRecordHierarchySnapshot(CheatEngine.Client.Tables.MemoryRecordSnapshot Record, System.Collections.Immutable.ImmutableArray Children) -> void -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Record.get -> CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Record.init -> void -CheatEngine.Client.Tables.MemoryRecordSearch -CheatEngine.Client.Tables.MemoryRecordSearch.AddressExpression.get -> string? -CheatEngine.Client.Tables.MemoryRecordSearch.DescriptionContains.get -> string? -CheatEngine.Client.Tables.MemoryRecordSearch.Equals(CheatEngine.Client.Tables.MemoryRecordSearch other) -> bool -CheatEngine.Client.Tables.MemoryRecordSearch.IsActive.get -> bool? -CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch() -> void -CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch(string? descriptionContains = null, string? addressExpression = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null, bool? isActive = null) -> void -CheatEngine.Client.Tables.MemoryRecordSearch.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? -CheatEngine.Client.Tables.MemoryRecordContentSnapshot -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.AddressExpression.get -> string! -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Description.get -> string! -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordContentSnapshot other) -> bool -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.MemoryRecordContentSnapshot() -> void -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.MemoryRecordContentSnapshot(string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType) -> void -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Value.get -> string! -CheatEngine.Client.Tables.MemoryRecordContentSnapshot.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType -CheatEngine.Client.Tables.MemoryRecordSnapshot -CheatEngine.Client.Tables.MemoryRecordSnapshot.AddressExpression.get -> string! -CheatEngine.Client.Tables.MemoryRecordSnapshot.ChildCount.get -> int -CheatEngine.Client.Tables.MemoryRecordSnapshot.CurrentAddress.get -> CheatEngine.SDK.Engine.Values.Address? -CheatEngine.Client.Tables.MemoryRecordSnapshot.Description.get -> string! -CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordSnapshot other) -> bool -CheatEngine.Client.Tables.MemoryRecordSnapshot.Id.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId -CheatEngine.Client.Tables.MemoryRecordSnapshot.Index.get -> int -CheatEngine.Client.Tables.MemoryRecordSnapshot.IsActive.get -> bool -CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot() -> void -CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, int index, CheatEngine.Client.Tables.MemoryRecordContentSnapshot content, CheatEngine.Client.Tables.MemoryRecordStateSnapshot state) -> void -CheatEngine.Client.Tables.MemoryRecordSnapshot.Content.get -> CheatEngine.Client.Tables.MemoryRecordContentSnapshot -CheatEngine.Client.Tables.MemoryRecordSnapshot.State.get -> CheatEngine.Client.Tables.MemoryRecordStateSnapshot -CheatEngine.Client.Tables.MemoryRecordSnapshot.Value.get -> string! -CheatEngine.Client.Tables.MemoryRecordSnapshot.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType -CheatEngine.Client.Tables.MemoryRecordStateSnapshot -CheatEngine.Client.Tables.MemoryRecordStateSnapshot.ChildCount.get -> int -CheatEngine.Client.Tables.MemoryRecordStateSnapshot.CurrentAddress.get -> CheatEngine.SDK.Engine.Values.Address? -CheatEngine.Client.Tables.MemoryRecordStateSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordStateSnapshot other) -> bool -CheatEngine.Client.Tables.MemoryRecordStateSnapshot.IsActive.get -> bool -CheatEngine.Client.Tables.MemoryRecordStateSnapshot.MemoryRecordStateSnapshot() -> void -CheatEngine.Client.Tables.MemoryRecordStateSnapshot.MemoryRecordStateSnapshot(CheatEngine.SDK.Engine.Values.Address? currentAddress, bool isActive = false, int childCount = 0) -> void -CheatEngine.Client.Tables.MemoryRecordUpdate -CheatEngine.Client.Tables.MemoryRecordUpdate.AddressExpression.get -> string? -CheatEngine.Client.Tables.MemoryRecordUpdate.Description.get -> string? -CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(CheatEngine.Client.Tables.MemoryRecordUpdate other) -> bool -CheatEngine.Client.Tables.MemoryRecordUpdate.Id.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId -CheatEngine.Client.Tables.MemoryRecordUpdate.MemoryRecordUpdate() -> void -CheatEngine.Client.Tables.MemoryRecordUpdate.MemoryRecordUpdate(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, string? description = null, string? addressExpression = null, string? value = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null) -> void -CheatEngine.Client.Tables.MemoryRecordUpdate.Value.get -> string? -CheatEngine.Client.Tables.MemoryRecordUpdate.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? -CheatEngine.Client.Tables.TableLoadRequest -CheatEngine.Client.Tables.TableLoadRequest.Deconstruct(out CheatEngine.Client.Tables.TrustedTableFile File, out bool Merge) -> void -CheatEngine.Client.Tables.TableLoadRequest.Equals(CheatEngine.Client.Tables.TableLoadRequest other) -> bool -CheatEngine.Client.Tables.TableLoadRequest.File.get -> CheatEngine.Client.Tables.TrustedTableFile -CheatEngine.Client.Tables.TableLoadRequest.File.init -> void -CheatEngine.Client.Tables.TableLoadRequest.Merge.get -> bool -CheatEngine.Client.Tables.TableLoadRequest.Merge.init -> void -CheatEngine.Client.Tables.TableLoadRequest.TableLoadRequest() -> void -CheatEngine.Client.Tables.TableLoadRequest.TableLoadRequest(CheatEngine.Client.Tables.TrustedTableFile File, bool Merge = false) -> void -CheatEngine.Client.Tables.TableSaveRequest -CheatEngine.Client.Tables.TableSaveRequest.Deconstruct(out CheatEngine.Client.Tables.TrustedTableFile File) -> void -CheatEngine.Client.Tables.TableSaveRequest.Equals(CheatEngine.Client.Tables.TableSaveRequest other) -> bool -CheatEngine.Client.Tables.TableSaveRequest.File.get -> CheatEngine.Client.Tables.TrustedTableFile -CheatEngine.Client.Tables.TableSaveRequest.File.init -> void -CheatEngine.Client.Tables.TableSaveRequest.TableSaveRequest() -> void -CheatEngine.Client.Tables.TableSaveRequest.TableSaveRequest(CheatEngine.Client.Tables.TrustedTableFile File) -> void -CheatEngine.Client.Tables.TrustedTableFile -CheatEngine.Client.Tables.TrustedTableFile.Equals(CheatEngine.Client.Tables.TrustedTableFile other) -> bool -CheatEngine.Client.Tables.TrustedTableFile.FullPath.get -> string! -CheatEngine.Client.Tables.TrustedTableFile.TrustedTableFile() -> void -CheatEngine.Client.Tables.TrustedTableFile.TrustedTableFile(string! path) -> void -override CheatEngine.Client.Inspection.InspectionCollectionRequest.GetHashCode() -> int -override CheatEngine.Client.Inspection.SymbolRegistration.GetHashCode() -> int -override CheatEngine.Client.Lua.LuaScript.GetHashCode() -> int -override CheatEngine.Client.Memory.MemoryBytesReadRequest.GetHashCode() -> int -override CheatEngine.Client.Memory.MemoryBytesWriteRequest.GetHashCode() -> int -override CheatEngine.Client.Memory.MemoryReadRequest.GetHashCode() -> int -override CheatEngine.Client.Memory.MemoryStringReadRequest.GetHashCode() -> int -override CheatEngine.Client.Memory.MemoryStringWriteRequest.GetHashCode() -> int -override CheatEngine.Client.Memory.MemoryWriteRequest.GetHashCode() -> int -override CheatEngine.Client.Memory.PointerChainRequest.GetHashCode() -> int -override CheatEngine.Client.Processes.ProcessSnapshot.GetHashCode() -> int -override CheatEngine.Client.Results.CheatEngineFailure.GetHashCode() -> int -override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.GetHashCode() -> int -override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.GetHashCode() -> int -override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.GetHashCode() -> int -override CheatEngine.Client.Runtime.ClientCapabilities.Equals(object? obj) -> bool -override CheatEngine.Client.Runtime.ClientCapabilities.GetHashCode() -> int -override CheatEngine.Client.Runtime.ClientCapabilityAvailability.GetHashCode() -> int -override CheatEngine.Client.Runtime.ClientCapabilityId.Equals(object? obj) -> bool -override CheatEngine.Client.Runtime.ClientCapabilityId.GetHashCode() -> int -override CheatEngine.Client.Runtime.ClientCapabilityId.ToString() -> string! -override CheatEngine.Client.Scanning.AobPattern.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobPattern.ToString() -> string! -override CheatEngine.Client.Scanning.AobScanRange.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobScanRequest.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobScanResult.GetHashCode() -> int -override CheatEngine.Client.Scanning.ValueScanMatch.GetHashCode() -> int -override CheatEngine.Client.Scanning.ValueScanPage.GetHashCode() -> int -override CheatEngine.Client.Scanning.ValueScanReadRequest.GetHashCode() -> int -override CheatEngine.Client.Tables.AddressTableSnapshot.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordDefinition.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordSearch.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordSnapshot.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.GetHashCode() -> int -override CheatEngine.Client.Tables.MemoryRecordUpdate.GetHashCode() -> int -override CheatEngine.Client.Tables.TableLoadRequest.GetHashCode() -> int -override CheatEngine.Client.Tables.TableSaveRequest.GetHashCode() -> int -override CheatEngine.Client.Tables.TrustedTableFile.GetHashCode() -> int -static CheatEngine.Client.Inspection.InspectionCollectionRequest.operator !=(CheatEngine.Client.Inspection.InspectionCollectionRequest left, CheatEngine.Client.Inspection.InspectionCollectionRequest right) -> bool -static CheatEngine.Client.Inspection.InspectionCollectionRequest.operator ==(CheatEngine.Client.Inspection.InspectionCollectionRequest left, CheatEngine.Client.Inspection.InspectionCollectionRequest right) -> bool -static CheatEngine.Client.Inspection.SymbolRegistration.operator !=(CheatEngine.Client.Inspection.SymbolRegistration left, CheatEngine.Client.Inspection.SymbolRegistration right) -> bool -static CheatEngine.Client.Inspection.SymbolRegistration.operator ==(CheatEngine.Client.Inspection.SymbolRegistration left, CheatEngine.Client.Inspection.SymbolRegistration right) -> bool -static CheatEngine.Client.Lua.LuaScript.operator !=(CheatEngine.Client.Lua.LuaScript left, CheatEngine.Client.Lua.LuaScript right) -> bool -static CheatEngine.Client.Lua.LuaScript.operator ==(CheatEngine.Client.Lua.LuaScript left, CheatEngine.Client.Lua.LuaScript right) -> bool -static CheatEngine.Client.Memory.MemoryBytesReadRequest.operator !=(CheatEngine.Client.Memory.MemoryBytesReadRequest left, CheatEngine.Client.Memory.MemoryBytesReadRequest right) -> bool -static CheatEngine.Client.Memory.MemoryBytesReadRequest.operator ==(CheatEngine.Client.Memory.MemoryBytesReadRequest left, CheatEngine.Client.Memory.MemoryBytesReadRequest right) -> bool -static CheatEngine.Client.Memory.MemoryBytesWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryBytesWriteRequest left, CheatEngine.Client.Memory.MemoryBytesWriteRequest right) -> bool -static CheatEngine.Client.Memory.MemoryBytesWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryBytesWriteRequest left, CheatEngine.Client.Memory.MemoryBytesWriteRequest right) -> bool -static CheatEngine.Client.Memory.MemoryReadRequest.operator !=(CheatEngine.Client.Memory.MemoryReadRequest left, CheatEngine.Client.Memory.MemoryReadRequest right) -> bool -static CheatEngine.Client.Memory.MemoryReadRequest.operator ==(CheatEngine.Client.Memory.MemoryReadRequest left, CheatEngine.Client.Memory.MemoryReadRequest right) -> bool -static CheatEngine.Client.Memory.MemoryStringReadRequest.operator !=(CheatEngine.Client.Memory.MemoryStringReadRequest left, CheatEngine.Client.Memory.MemoryStringReadRequest right) -> bool -static CheatEngine.Client.Memory.MemoryStringReadRequest.operator ==(CheatEngine.Client.Memory.MemoryStringReadRequest left, CheatEngine.Client.Memory.MemoryStringReadRequest right) -> bool -static CheatEngine.Client.Memory.MemoryStringWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryStringWriteRequest left, CheatEngine.Client.Memory.MemoryStringWriteRequest right) -> bool -static CheatEngine.Client.Memory.MemoryStringWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryStringWriteRequest left, CheatEngine.Client.Memory.MemoryStringWriteRequest right) -> bool -static CheatEngine.Client.Memory.MemoryWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryWriteRequest left, CheatEngine.Client.Memory.MemoryWriteRequest right) -> bool -static CheatEngine.Client.Memory.MemoryWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryWriteRequest left, CheatEngine.Client.Memory.MemoryWriteRequest right) -> bool -static CheatEngine.Client.Memory.PointerChainRequest.operator !=(CheatEngine.Client.Memory.PointerChainRequest left, CheatEngine.Client.Memory.PointerChainRequest right) -> bool -static CheatEngine.Client.Memory.PointerChainRequest.operator ==(CheatEngine.Client.Memory.PointerChainRequest left, CheatEngine.Client.Memory.PointerChainRequest right) -> bool -static CheatEngine.Client.Processes.ProcessSnapshot.operator !=(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool -static CheatEngine.Client.Processes.ProcessSnapshot.operator ==(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool -static CheatEngine.Client.Results.CheatEngineFailure.operator !=(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool -static CheatEngine.Client.Results.CheatEngineFailure.operator ==(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool -static CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo right) -> bool -static CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo right) -> bool -static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool -static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool -static CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo right) -> bool -static CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo right) -> bool -static CheatEngine.Client.Runtime.ClientCapabilities.Create(System.ReadOnlySpan entries) -> CheatEngine.Client.Runtime.ClientCapabilities! -static CheatEngine.Client.Runtime.ClientCapabilities.Empty.get -> CheatEngine.Client.Runtime.ClientCapabilities! -static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator !=(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool -static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator ==(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool -static CheatEngine.Client.Runtime.ClientCapabilityId.Inspection.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.operator !=(CheatEngine.Client.Runtime.ClientCapabilityId left, CheatEngine.Client.Runtime.ClientCapabilityId right) -> bool -static CheatEngine.Client.Runtime.ClientCapabilityId.operator ==(CheatEngine.Client.Runtime.ClientCapabilityId left, CheatEngine.Client.Runtime.ClientCapabilityId right) -> bool -static CheatEngine.Client.Runtime.ClientCapabilityId.PatternScanning.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.ProcessSelection.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.ProtectedLua.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Tables.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.TypedMemory.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.UnsafeLuaExecution.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.ValueScanning.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Scanning.AobPattern.operator !=(CheatEngine.Client.Scanning.AobPattern left, CheatEngine.Client.Scanning.AobPattern right) -> bool -static CheatEngine.Client.Scanning.AobPattern.operator ==(CheatEngine.Client.Scanning.AobPattern left, CheatEngine.Client.Scanning.AobPattern right) -> bool -static CheatEngine.Client.Scanning.AobPattern.TryParse(string? value, out CheatEngine.Client.Scanning.AobPattern pattern) -> bool -static CheatEngine.Client.Scanning.AobScanRange.operator !=(CheatEngine.Client.Scanning.AobScanRange left, CheatEngine.Client.Scanning.AobScanRange right) -> bool -static CheatEngine.Client.Scanning.AobScanRange.operator ==(CheatEngine.Client.Scanning.AobScanRange left, CheatEngine.Client.Scanning.AobScanRange right) -> bool -static CheatEngine.Client.Scanning.AobScanRequest.operator !=(CheatEngine.Client.Scanning.AobScanRequest left, CheatEngine.Client.Scanning.AobScanRequest right) -> bool -static CheatEngine.Client.Scanning.AobScanRequest.operator ==(CheatEngine.Client.Scanning.AobScanRequest left, CheatEngine.Client.Scanning.AobScanRequest right) -> bool -static CheatEngine.Client.Scanning.AobScanResult.operator !=(CheatEngine.Client.Scanning.AobScanResult left, CheatEngine.Client.Scanning.AobScanResult right) -> bool -static CheatEngine.Client.Scanning.AobScanResult.operator ==(CheatEngine.Client.Scanning.AobScanResult left, CheatEngine.Client.Scanning.AobScanResult right) -> bool -static CheatEngine.Client.Scanning.ValueScanMatch.operator !=(CheatEngine.Client.Scanning.ValueScanMatch left, CheatEngine.Client.Scanning.ValueScanMatch right) -> bool -static CheatEngine.Client.Scanning.ValueScanMatch.operator ==(CheatEngine.Client.Scanning.ValueScanMatch left, CheatEngine.Client.Scanning.ValueScanMatch right) -> bool -static CheatEngine.Client.Scanning.ValueScanPage.operator !=(CheatEngine.Client.Scanning.ValueScanPage left, CheatEngine.Client.Scanning.ValueScanPage right) -> bool -static CheatEngine.Client.Scanning.ValueScanPage.operator ==(CheatEngine.Client.Scanning.ValueScanPage left, CheatEngine.Client.Scanning.ValueScanPage right) -> bool -static CheatEngine.Client.Scanning.ValueScanReadRequest.operator !=(CheatEngine.Client.Scanning.ValueScanReadRequest left, CheatEngine.Client.Scanning.ValueScanReadRequest right) -> bool -static CheatEngine.Client.Scanning.ValueScanReadRequest.operator ==(CheatEngine.Client.Scanning.ValueScanReadRequest left, CheatEngine.Client.Scanning.ValueScanReadRequest right) -> bool -static CheatEngine.Client.Tables.AddressTableSnapshot.operator !=(CheatEngine.Client.Tables.AddressTableSnapshot left, CheatEngine.Client.Tables.AddressTableSnapshot right) -> bool -static CheatEngine.Client.Tables.AddressTableSnapshot.operator ==(CheatEngine.Client.Tables.AddressTableSnapshot left, CheatEngine.Client.Tables.AddressTableSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordCollectionRequest.operator !=(CheatEngine.Client.Tables.MemoryRecordCollectionRequest left, CheatEngine.Client.Tables.MemoryRecordCollectionRequest right) -> bool -static CheatEngine.Client.Tables.MemoryRecordCollectionRequest.operator ==(CheatEngine.Client.Tables.MemoryRecordCollectionRequest left, CheatEngine.Client.Tables.MemoryRecordCollectionRequest right) -> bool -static CheatEngine.Client.Tables.MemoryRecordDefinition.operator !=(CheatEngine.Client.Tables.MemoryRecordDefinition left, CheatEngine.Client.Tables.MemoryRecordDefinition right) -> bool -static CheatEngine.Client.Tables.MemoryRecordDefinition.operator ==(CheatEngine.Client.Tables.MemoryRecordDefinition left, CheatEngine.Client.Tables.MemoryRecordDefinition right) -> bool -static CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.operator !=(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest left, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest right) -> bool -static CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.operator ==(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest left, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest right) -> bool -static CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot left, CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot left, CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordSearch.operator !=(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool -static CheatEngine.Client.Tables.MemoryRecordSearch.operator ==(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool -static CheatEngine.Client.Tables.MemoryRecordContentSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordContentSnapshot left, CheatEngine.Client.Tables.MemoryRecordContentSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordContentSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordContentSnapshot left, CheatEngine.Client.Tables.MemoryRecordContentSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordStateSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordStateSnapshot left, CheatEngine.Client.Tables.MemoryRecordStateSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordStateSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordStateSnapshot left, CheatEngine.Client.Tables.MemoryRecordStateSnapshot right) -> bool -static CheatEngine.Client.Tables.MemoryRecordUpdate.operator !=(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool -static CheatEngine.Client.Tables.MemoryRecordUpdate.operator ==(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool -static CheatEngine.Client.Tables.TableLoadRequest.operator !=(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool -static CheatEngine.Client.Tables.TableLoadRequest.operator ==(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool -static CheatEngine.Client.Tables.TableSaveRequest.operator !=(CheatEngine.Client.Tables.TableSaveRequest left, CheatEngine.Client.Tables.TableSaveRequest right) -> bool -static CheatEngine.Client.Tables.TableSaveRequest.operator ==(CheatEngine.Client.Tables.TableSaveRequest left, CheatEngine.Client.Tables.TableSaveRequest right) -> bool -static CheatEngine.Client.Tables.TrustedTableFile.operator !=(CheatEngine.Client.Tables.TrustedTableFile left, CheatEngine.Client.Tables.TrustedTableFile right) -> bool -static CheatEngine.Client.Tables.TrustedTableFile.operator ==(CheatEngine.Client.Tables.TrustedTableFile left, CheatEngine.Client.Tables.TrustedTableFile right) -> bool diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt index 577c11e..38c900e 100644 --- a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt @@ -1,35 +1,221 @@ #nullable enable -CheatEngine.Client.Memory.IMemoryBatchClient -CheatEngine.Client.Memory.IMemoryBatchClient.ReadPrimitiveBatchDetailed(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome! -CheatEngine.Client.Memory.IMemoryBatchClient.WritePrimitiveBatchDetailed(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome! +CheatEngine.Client.Dispatching.ICheatEngineDispatcher +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Action! callback) -> void +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Action! callback, System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Func! callback) -> T +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Func! callback, System.Threading.CancellationToken cancellationToken) -> T +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.IsMainThread.get -> bool +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Action! callback, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Action! callback, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken) -> bool +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Func! callback, out T result, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Func! callback, out T result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken) -> bool +CheatEngine.Client.ICheatEngineClient +CheatEngine.Client.ICheatEngineClient.Dispatcher.get -> CheatEngine.Client.Dispatching.ICheatEngineDispatcher! +CheatEngine.Client.ICheatEngineClient.Epoch.get -> long +CheatEngine.Client.ICheatEngineClient.Inspection.get -> CheatEngine.Client.Inspection.IInspectionClient! +CheatEngine.Client.ICheatEngineClient.Lua.get -> CheatEngine.Client.Lua.ILuaClient! +CheatEngine.Client.ICheatEngineClient.Memory.get -> CheatEngine.Client.Memory.IMemoryClient! +CheatEngine.Client.ICheatEngineClient.Patterns.get -> CheatEngine.Client.Scanning.IPatternScanner! +CheatEngine.Client.ICheatEngineClient.Processes.get -> CheatEngine.Client.Processes.IProcessClient! +CheatEngine.Client.ICheatEngineClient.Runtime.get -> CheatEngine.Client.Runtime.ICheatEngineRuntime! +CheatEngine.Client.ICheatEngineClient.Stopping.get -> System.Threading.CancellationToken +CheatEngine.Client.ICheatEngineClient.Tables.get -> CheatEngine.Client.Tables.ITableClient! +CheatEngine.Client.ICheatEngineLease +CheatEngine.Client.ICheatEngineLease.IsReleased.get -> bool +CheatEngine.Client.ICheatEngineLease.LastReleaseOutcome.get -> CheatEngine.Client.Results.LeaseReleaseOutcome? +CheatEngine.Client.ICheatEngineLease.Release() -> CheatEngine.Client.Results.LeaseReleaseOutcome +CheatEngine.Client.ICheatEngineLease.RequiresManualRecovery.get -> bool +CheatEngine.Client.Inspection.AddressResolutionMode +CheatEngine.Client.Inspection.AddressResolutionMode.Default = 0 -> CheatEngine.Client.Inspection.AddressResolutionMode +CheatEngine.Client.Inspection.AddressResolutionMode.Shallow = 1 -> CheatEngine.Client.Inspection.AddressResolutionMode +CheatEngine.Client.Inspection.IInspectionClient +CheatEngine.Client.Inspection.IInspectionClient.GetMemoryRegion(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo +CheatEngine.Client.Inspection.IInspectionClient.GetMemoryRegions(CheatEngine.Client.Inspection.InspectionCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Inspection.IInspectionClient.GetModuleSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, CheatEngine.Client.Inspection.InspectionCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Inspection.IInspectionClient.GetModules(CheatEngine.Client.Inspection.InspectionCollectionRequest request, CheatEngine.SDK.Engine.Inspection.TargetProcessId? processId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Inspection.IInspectionClient.GetSymbol(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Inspection.SymbolInfo +CheatEngine.Client.Inspection.IInspectionClient.RegisterSymbol(CheatEngine.Client.Inspection.SymbolRegistration registration, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Inspection.ISymbolRegistrationLease! +CheatEngine.Client.Inspection.IInspectionClient.ResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.Client.Inspection.AddressResolutionMode mode, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Inspection.IInspectionClient.ResolveName(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Inspection.IInspectionClient.TryGetMemoryRegion(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo region, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetMemoryRegions(CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray regions, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetModuleSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray sections, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetModules(CheatEngine.Client.Inspection.InspectionCollectionRequest request, CheatEngine.SDK.Engine.Inspection.TargetProcessId? processId, out System.Collections.Immutable.ImmutableArray modules, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetSymbol(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryRegisterSymbol(CheatEngine.Client.Inspection.SymbolRegistration registration, out CheatEngine.Client.Inspection.ISymbolRegistrationLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.Client.Inspection.AddressResolutionMode mode, out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryResolveName(CheatEngine.SDK.Engine.Values.Address address, out string? name, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.ISymbolRegistrationLease +CheatEngine.Client.Inspection.ISymbolRegistrationLease.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Inspection.ISymbolRegistrationLease.Name.get -> string! +CheatEngine.Client.Inspection.InspectionCollectionRequest +CheatEngine.Client.Inspection.InspectionCollectionRequest.Equals(CheatEngine.Client.Inspection.InspectionCollectionRequest other) -> bool +CheatEngine.Client.Inspection.InspectionCollectionRequest.InspectionCollectionRequest() -> void +CheatEngine.Client.Inspection.InspectionCollectionRequest.InspectionCollectionRequest(int maximumItems) -> void +CheatEngine.Client.Inspection.InspectionCollectionRequest.MaximumItems.get -> int +CheatEngine.Client.Inspection.SymbolRegistration +CheatEngine.Client.Inspection.SymbolRegistration.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Inspection.SymbolRegistration.DoNotSave.get -> bool +CheatEngine.Client.Inspection.SymbolRegistration.Equals(CheatEngine.Client.Inspection.SymbolRegistration other) -> bool +CheatEngine.Client.Inspection.SymbolRegistration.Name.get -> string! +CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration() -> void +CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration(string! name, CheatEngine.SDK.Engine.Values.Address address, bool doNotSave = true) -> void +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.BindingsType.get -> System.Type! +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.CheatEngineLuaModuleAttribute(System.Type! bindingsType, string? name = null) -> void +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.Name.get -> string? +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.CheatEngineLuaOperationAttribute() -> void +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.CheatEngineLuaOperationAttribute(System.Type! mapperType) -> void +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.MapperType.get -> System.Type? +CheatEngine.Client.Lua.ILuaClient +CheatEngine.Client.Lua.ILuaClient.Execute(in TOperation operation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +CheatEngine.Client.Lua.ILuaClient.RegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Lua.ILuaModuleLease! +CheatEngine.Client.Lua.ILuaClient.TryExecute(in TOperation operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.ILuaClient.TryRegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, out CheatEngine.Client.Lua.ILuaModuleLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.ILuaExecutionContext +CheatEngine.Client.Lua.ILuaExecutionContext.Epoch.get -> long +CheatEngine.Client.Lua.ILuaExecutionContext.IsActive.get -> bool +CheatEngine.Client.Lua.ILuaExecutionContext.ThrowIfExpired() -> void +CheatEngine.Client.Lua.ILuaModule +CheatEngine.Client.Lua.ILuaModule.Descriptor.get -> CheatEngine.Client.Lua.LuaModuleDescriptor +CheatEngine.Client.Lua.ILuaModule.Register() -> void +CheatEngine.Client.Lua.ILuaModule.Unregister() -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +CheatEngine.Client.Lua.ILuaModuleLease +CheatEngine.Client.Lua.ILuaModuleLease.LastModuleReleaseOutcome.get -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome? +CheatEngine.Client.Lua.ILuaOperation +CheatEngine.Client.Lua.ILuaOperation.TryExecute(CheatEngine.Client.Lua.ILuaExecutionContext! context, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Lua.ILuaResultMapper +CheatEngine.Client.Lua.ILuaResultMapper.Map(TSource source) -> TResult +CheatEngine.Client.Lua.IUnsafeLuaClient +CheatEngine.Client.Lua.IUnsafeLuaClient.Execute(CheatEngine.Client.Lua.LuaScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Lua.IUnsafeLuaClient.TryExecute(CheatEngine.Client.Lua.LuaScript script, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.LuaExportDescriptor +CheatEngine.Client.Lua.LuaExportDescriptor.Equals(CheatEngine.Client.Lua.LuaExportDescriptor other) -> bool +CheatEngine.Client.Lua.LuaExportDescriptor.LuaExportDescriptor() -> void +CheatEngine.Client.Lua.LuaExportDescriptor.LuaExportDescriptor(string! name) -> void +CheatEngine.Client.Lua.LuaExportDescriptor.Name.get -> string! +CheatEngine.Client.Lua.LuaModuleDescriptor +CheatEngine.Client.Lua.LuaModuleDescriptor.Exports.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Lua.LuaModuleDescriptor.LuaModuleDescriptor() -> void +CheatEngine.Client.Lua.LuaModuleDescriptor.LuaModuleDescriptor(string! name, System.Collections.Immutable.ImmutableArray exports) -> void +CheatEngine.Client.Lua.LuaModuleDescriptor.Name.get -> string! +CheatEngine.Client.Lua.LuaModuleReleaseOutcome +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.FailedExports.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.IsComplete.get -> bool +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.Kind.get -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.ModuleName.get -> string! +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.RemainingCount.get -> int +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.RemovedCount.get -> int +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.ReplacementCount.get -> int +CheatEngine.Client.Lua.LuaModuleReleaseOutcome.RestoredCount.get -> int +CheatEngine.Client.Lua.LuaScript +CheatEngine.Client.Lua.LuaScript.ChunkName.get -> string? +CheatEngine.Client.Lua.LuaScript.Equals(CheatEngine.Client.Lua.LuaScript other) -> bool +CheatEngine.Client.Lua.LuaScript.LuaScript() -> void +CheatEngine.Client.Lua.LuaScript.LuaScript(string! source, string? chunkName = null) -> void +CheatEngine.Client.Lua.LuaScript.Source.get -> string! +CheatEngine.Client.Memory.IMemoryClient +CheatEngine.Client.Memory.IMemoryClient.Read(CheatEngine.Client.Memory.MemoryReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.IMemoryClient.ReadBytes(CheatEngine.Client.Memory.MemoryBytesReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.IMemoryClient.ReadBytesDetailed(CheatEngine.Client.Memory.MemoryBytesReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Memory.MemoryBytesReadOutcome! +CheatEngine.Client.Memory.IMemoryClient.ReadPrimitive(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.IMemoryClient.ReadPrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.IMemoryClient.ReadPrimitiveBatchDetailed(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome! +CheatEngine.Client.Memory.IMemoryClient.ReadString(CheatEngine.Client.Memory.MemoryStringReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Memory.IMemoryClient.ResolvePointerChain(CheatEngine.Client.Memory.PointerChainRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.IMemoryClient.TryRead(CheatEngine.Client.Memory.MemoryReadRequest request, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadBytes(CheatEngine.Client.Memory.MemoryBytesReadRequest request, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadPrimitive(CheatEngine.SDK.Engine.Values.Address address, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadPrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, out System.Collections.Immutable.ImmutableArray values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadString(CheatEngine.Client.Memory.MemoryStringReadRequest request, out string? value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryResolvePointerChain(CheatEngine.Client.Memory.PointerChainRequest request, out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWrite(CheatEngine.Client.Memory.MemoryWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWriteBytes(CheatEngine.Client.Memory.MemoryBytesWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWritePrimitive(CheatEngine.SDK.Engine.Values.Address address, T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWritePrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWriteString(CheatEngine.Client.Memory.MemoryStringWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.Write(CheatEngine.Client.Memory.MemoryWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WriteBytes(CheatEngine.Client.Memory.MemoryBytesWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WritePrimitive(CheatEngine.SDK.Engine.Values.Address address, T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WritePrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WritePrimitiveBatchDetailed(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome! +CheatEngine.Client.Memory.IMemoryClient.WriteString(CheatEngine.Client.Memory.MemoryStringWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryCodec +CheatEngine.Client.Memory.IMemoryCodec.TryRead(CheatEngine.Client.Memory.IMemoryReadContext! context, CheatEngine.SDK.Engine.Values.Address address, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Memory.IMemoryCodec.TryWrite(CheatEngine.Client.Memory.IMemoryWriteContext! context, CheatEngine.SDK.Engine.Values.Address address, in T value, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Memory.IMemoryReadContext +CheatEngine.Client.Memory.IMemoryReadContext.Bitness.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Memory.IMemoryReadContext.ConfiguredPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Memory.IMemoryReadContext.ConfiguredPointerSizeBytes.get -> int? +CheatEngine.Client.Memory.IMemoryReadContext.ConfiguredPointerSizeDiffersFromBitness.get -> bool? +CheatEngine.Client.Memory.IMemoryReadContext.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Memory.IMemoryWriteContext +CheatEngine.Client.Memory.IMemoryWriteContext.Bitness.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Memory.IMemoryWriteContext.ConfiguredPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Memory.IMemoryWriteContext.ConfiguredPointerSizeBytes.get -> int? +CheatEngine.Client.Memory.IMemoryWriteContext.ConfiguredPointerSizeDiffersFromBitness.get -> bool? +CheatEngine.Client.Memory.IMemoryWriteContext.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan source, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Memory.MemoryAddressValue +CheatEngine.Client.Memory.MemoryAddressValue.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryAddressValue.MemoryAddressValue() -> void +CheatEngine.Client.Memory.MemoryAddressValue.MemoryAddressValue(CheatEngine.SDK.Engine.Values.Address address, T value) -> void +CheatEngine.Client.Memory.MemoryAddressValue.Value.get -> T +CheatEngine.Client.Memory.MemoryBatchLimits CheatEngine.Client.Memory.MemoryBatchWriteEffectState -CheatEngine.Client.Memory.MemoryBatchWriteEffectState.Complete = 2 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState -CheatEngine.Client.Memory.MemoryBatchWriteEffectState.NotStarted = 0 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState -CheatEngine.Client.Memory.MemoryBatchWriteEffectState.Partial = 1 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState -CheatEngine.Client.Memory.MemoryBatchWriteEffectState.Unknown = 3 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState +CheatEngine.Client.Memory.MemoryBatchWriteEffectState.Completed = 3 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState +CheatEngine.Client.Memory.MemoryBatchWriteEffectState.NotStarted = 1 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState +CheatEngine.Client.Memory.MemoryBatchWriteEffectState.Partial = 2 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState +CheatEngine.Client.Memory.MemoryBatchWriteEffectState.Unknown = 0 -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState +CheatEngine.Client.Memory.MemoryBytesReadOutcome +CheatEngine.Client.Memory.MemoryBytesReadOutcome.Bytes.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryBytesReadOutcome.ConfirmedLength.get -> int +CheatEngine.Client.Memory.MemoryBytesReadOutcome.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure? +CheatEngine.Client.Memory.MemoryBytesReadOutcome.IsSuccess.get -> bool +CheatEngine.Client.Memory.MemoryBytesReadOutcome.MemoryBytesReadOutcome(int requestedLength, System.Collections.Immutable.ImmutableArray bytes, CheatEngine.Client.Results.CheatEngineFailure? failure) -> void +CheatEngine.Client.Memory.MemoryBytesReadOutcome.RequestedLength.get -> int +CheatEngine.Client.Memory.MemoryBytesReadRequest +CheatEngine.Client.Memory.MemoryBytesReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryBytesReadRequest.Equals(CheatEngine.Client.Memory.MemoryBytesReadRequest other) -> bool +CheatEngine.Client.Memory.MemoryBytesReadRequest.Length.get -> int +CheatEngine.Client.Memory.MemoryBytesReadRequest.MemoryBytesReadRequest() -> void +CheatEngine.Client.Memory.MemoryBytesReadRequest.MemoryBytesReadRequest(CheatEngine.SDK.Engine.Values.Address address, int length) -> void +CheatEngine.Client.Memory.MemoryBytesWriteRequest +CheatEngine.Client.Memory.MemoryBytesWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryBytesWriteRequest.Bytes.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryBytesWriteRequest.MemoryBytesWriteRequest() -> void +CheatEngine.Client.Memory.MemoryBytesWriteRequest.MemoryBytesWriteRequest(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan bytes) -> void CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.AttemptedCount.get -> int -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.Cause.get -> CheatEngine.Client.Results.CheatEngineFailure? CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.CompletedCount.get -> int CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.FailedIndex.get -> int? -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.MemoryPrimitiveBatchReadOutcome(int attemptedCount, int completedCount, int? failedIndex, CheatEngine.Client.Results.CheatEngineFailure? cause, System.ReadOnlySpan readPrefix) -> void -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.ReadPrefix.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.Succeeded.get -> bool +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure? +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.IsSuccess.get -> bool +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.MemoryPrimitiveBatchReadOutcome(int requestedCount, System.ReadOnlySpan values, int? failedIndex, CheatEngine.Client.Results.CheatEngineFailure? failure) -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.RequestedCount.get -> int +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadOutcome.Values.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.Addresses.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.MemoryPrimitiveBatchReadRequest() -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.MemoryPrimitiveBatchReadRequest(System.ReadOnlySpan addresses) -> void CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.AttemptedCount.get -> int -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.Cause.get -> CheatEngine.Client.Results.CheatEngineFailure? CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.CompletedCount.get -> int CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.EffectState.get -> CheatEngine.Client.Memory.MemoryBatchWriteEffectState CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.FailedIndex.get -> int? -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.MemoryPrimitiveBatchWriteOutcome(int attemptedCount, int completedCount, int? failedIndex, CheatEngine.Client.Results.CheatEngineFailure? cause, CheatEngine.Client.Memory.MemoryBatchWriteEffectState effectState) -> void -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.Succeeded.get -> bool +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure? +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.IsSuccess.get -> bool +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.MemoryPrimitiveBatchWriteOutcome(int requestedCount, int completedCount, int? failedIndex, CheatEngine.Client.Results.CheatEngineFailure? failure, CheatEngine.Client.Memory.MemoryBatchWriteEffectState effectState) -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteOutcome.RequestedCount.get -> int +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.MemoryPrimitiveBatchWriteRequest() -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.MemoryPrimitiveBatchWriteRequest(System.ReadOnlySpan> values) -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.Values.get -> System.Collections.Immutable.ImmutableArray> +CheatEngine.Client.Memory.MemoryReadRequest +CheatEngine.Client.Memory.MemoryReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryReadRequest.Codec.get -> CheatEngine.Client.Memory.IMemoryCodec! +CheatEngine.Client.Memory.MemoryReadRequest.Equals(CheatEngine.Client.Memory.MemoryReadRequest other) -> bool +CheatEngine.Client.Memory.MemoryReadRequest.MemoryReadRequest() -> void +CheatEngine.Client.Memory.MemoryReadRequest.MemoryReadRequest(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.Client.Memory.IMemoryCodec! codec) -> void CheatEngine.Client.Memory.MemoryResourceLimits -const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumBatchOperationCount = 1024 -> int -const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumBatchPayloadBytes = 65536 -> int -const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumReadBytes = 1048576 -> int -const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumStringBytes = 65536 -> int -const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumWriteBytes = 1048576 -> int -CheatEngine.Client.Memory.MemoryResourceLimits.CreateSnapshot() -> CheatEngine.Client.Memory.MemoryResourceLimits! CheatEngine.Client.Memory.MemoryResourceLimits.MaximumBatchOperationCount.get -> int CheatEngine.Client.Memory.MemoryResourceLimits.MaximumBatchOperationCount.set -> void CheatEngine.Client.Memory.MemoryResourceLimits.MaximumBatchPayloadBytes.get -> int @@ -42,588 +228,951 @@ CheatEngine.Client.Memory.MemoryResourceLimits.MaximumWriteBytes.get -> int CheatEngine.Client.Memory.MemoryResourceLimits.MaximumWriteBytes.set -> void CheatEngine.Client.Memory.MemoryResourceLimits.MemoryResourceLimits() -> void CheatEngine.Client.Memory.MemoryResourceLimits.MemoryResourceLimits(int maximumReadBytes, int maximumWriteBytes, int maximumStringBytes, int maximumBatchPayloadBytes, int maximumBatchOperationCount) -> void -CheatEngine.Client.Processes.ILocalProcessDiagnostics -CheatEngine.Client.Processes.ILocalProcessDiagnostics.GetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessEnumerationResult -CheatEngine.Client.Processes.ILocalProcessDiagnostics.TryGetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, out CheatEngine.Client.Processes.ProcessEnumerationResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringEncoding.Utf16 = 1 -> CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringEncoding.Utf8 = 0 -> CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringReadRequest +CheatEngine.Client.Memory.MemoryStringReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryStringReadRequest.Encoding.get -> CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringReadRequest.Equals(CheatEngine.Client.Memory.MemoryStringReadRequest other) -> bool +CheatEngine.Client.Memory.MemoryStringReadRequest.MaximumLength.get -> int +CheatEngine.Client.Memory.MemoryStringReadRequest.MemoryStringReadRequest() -> void +CheatEngine.Client.Memory.MemoryStringReadRequest.MemoryStringReadRequest(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding) -> void +CheatEngine.Client.Memory.MemoryStringWriteRequest +CheatEngine.Client.Memory.MemoryStringWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryStringWriteRequest.Encoding.get -> CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringWriteRequest.Equals(CheatEngine.Client.Memory.MemoryStringWriteRequest other) -> bool +CheatEngine.Client.Memory.MemoryStringWriteRequest.MaximumLength.get -> int +CheatEngine.Client.Memory.MemoryStringWriteRequest.MemoryStringWriteRequest() -> void +CheatEngine.Client.Memory.MemoryStringWriteRequest.MemoryStringWriteRequest(CheatEngine.SDK.Engine.Values.Address address, string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding) -> void +CheatEngine.Client.Memory.MemoryStringWriteRequest.Value.get -> string! +CheatEngine.Client.Memory.MemoryWriteRequest +CheatEngine.Client.Memory.MemoryWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryWriteRequest.Codec.get -> CheatEngine.Client.Memory.IMemoryCodec! +CheatEngine.Client.Memory.MemoryWriteRequest.Equals(CheatEngine.Client.Memory.MemoryWriteRequest other) -> bool +CheatEngine.Client.Memory.MemoryWriteRequest.MemoryWriteRequest() -> void +CheatEngine.Client.Memory.MemoryWriteRequest.MemoryWriteRequest(CheatEngine.SDK.Engine.Values.Address address, T value, CheatEngine.Client.Memory.IMemoryCodec! codec) -> void +CheatEngine.Client.Memory.MemoryWriteRequest.Value.get -> T +CheatEngine.Client.Memory.PointerChainRequest +CheatEngine.Client.Memory.PointerChainRequest.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.PointerChainRequest.Offsets.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.PointerChainRequest.PointerChainRequest() -> void +CheatEngine.Client.Memory.PointerChainRequest.PointerChainRequest(CheatEngine.SDK.Engine.Values.Address baseAddress, System.ReadOnlySpan offsets) -> void +CheatEngine.Client.Modules.ICheatEngineClientModule +CheatEngine.Client.Modules.ICheatEngineClientModule.OnDisabling(CheatEngine.Client.ICheatEngineClient! client) -> void +CheatEngine.Client.Modules.ICheatEngineClientModule.OnEnabled(CheatEngine.Client.ICheatEngineClient! client) -> void +CheatEngine.Client.Processes.IProcessClient +CheatEngine.Client.Processes.IProcessClient.Attach(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.AttachExactName(string! processName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.GetCurrentProcess(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.GetLocalProcesses(CheatEngine.Client.Processes.LocalProcessEnumerationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.LocalProcessEnumerationResult +CheatEngine.Client.Processes.IProcessClient.Refresh(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.TryAttach(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryAttachExactName(string! processName, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryGetCurrentProcess(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryGetLocalProcesses(CheatEngine.Client.Processes.LocalProcessEnumerationRequest request, out CheatEngine.Client.Processes.LocalProcessEnumerationResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryRefresh(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.LocalProcessEnumerationRequest +CheatEngine.Client.Processes.LocalProcessEnumerationRequest.Equals(CheatEngine.Client.Processes.LocalProcessEnumerationRequest other) -> bool +CheatEngine.Client.Processes.LocalProcessEnumerationRequest.LocalProcessEnumerationRequest() -> void +CheatEngine.Client.Processes.LocalProcessEnumerationRequest.LocalProcessEnumerationRequest(int maximumResults, string? nameContains = null) -> void +CheatEngine.Client.Processes.LocalProcessEnumerationRequest.MaximumResults.get -> int +CheatEngine.Client.Processes.LocalProcessEnumerationRequest.NameContains.get -> string? +CheatEngine.Client.Processes.LocalProcessEnumerationResult +CheatEngine.Client.Processes.LocalProcessEnumerationResult.IsTruncated.get -> bool +CheatEngine.Client.Processes.LocalProcessEnumerationResult.LocalProcessEnumerationResult() -> void +CheatEngine.Client.Processes.LocalProcessEnumerationResult.LocalProcessEnumerationResult(System.Collections.Immutable.ImmutableArray processes, bool isTruncated) -> void +CheatEngine.Client.Processes.LocalProcessEnumerationResult.Processes.get -> System.Collections.Immutable.ImmutableArray CheatEngine.Client.Processes.LocalProcessId CheatEngine.Client.Processes.LocalProcessId.Equals(CheatEngine.Client.Processes.LocalProcessId other) -> bool CheatEngine.Client.Processes.LocalProcessId.LocalProcessId() -> void CheatEngine.Client.Processes.LocalProcessId.LocalProcessId(int value) -> void CheatEngine.Client.Processes.LocalProcessId.Value.get -> int -~override CheatEngine.Client.Processes.LocalProcessId.Equals(object obj) -> bool -override CheatEngine.Client.Processes.LocalProcessId.GetHashCode() -> int -~override CheatEngine.Client.Processes.LocalProcessId.ToString() -> string -static CheatEngine.Client.Processes.LocalProcessId.operator !=(CheatEngine.Client.Processes.LocalProcessId left, CheatEngine.Client.Processes.LocalProcessId right) -> bool -static CheatEngine.Client.Processes.LocalProcessId.operator ==(CheatEngine.Client.Processes.LocalProcessId left, CheatEngine.Client.Processes.LocalProcessId right) -> bool -CheatEngine.Client.ICheatEngineClient.Allocations.get -> CheatEngine.Client.Allocations.IAllocationClient! -CheatEngine.Client.ICheatEngineClient.Assembly.get -> CheatEngine.Client.Assembly.IAssemblyClient! -CheatEngine.Client.ICheatEngineClient.Dbvm.get -> CheatEngine.Client.Dbvm.IDbvmClient! -CheatEngine.Client.ICheatEngineClient.Debugger.get -> CheatEngine.Client.Debugger.IDebuggerClient! -CheatEngine.Client.ICheatEngineClient.Hashing.get -> CheatEngine.Client.Hashing.IHashingClient! -CheatEngine.Client.ICheatEngineClient.Hotkeys.get -> CheatEngine.Client.Hotkeys.IHotkeyClient! -CheatEngine.Client.ICheatEngineClient.RemoteExecution.get -> CheatEngine.Client.RemoteExecution.IRemoteExecutionClient! -CheatEngine.Client.ICheatEngineClient.Speed.get -> CheatEngine.Client.Speed.ISpeedClient! -CheatEngine.Client.ICheatEngineClient.Timers.get -> CheatEngine.Client.Timers.ITimerClient! -static CheatEngine.Client.Runtime.ClientCapabilityId.Allocations.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Assembly.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Dbvm.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Debugger.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Hashing.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Hotkeys.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.RemoteExecution.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Speed.get -> CheatEngine.Client.Runtime.ClientCapabilityId -static CheatEngine.Client.Runtime.ClientCapabilityId.Timers.get -> CheatEngine.Client.Runtime.ClientCapabilityId +CheatEngine.Client.Processes.LocalProcessSnapshot +CheatEngine.Client.Processes.LocalProcessSnapshot.Equals(CheatEngine.Client.Processes.LocalProcessSnapshot other) -> bool +CheatEngine.Client.Processes.LocalProcessSnapshot.ExecutablePath.get -> string? +CheatEngine.Client.Processes.LocalProcessSnapshot.Id.get -> CheatEngine.Client.Processes.LocalProcessId +CheatEngine.Client.Processes.LocalProcessSnapshot.LocalProcessSnapshot() -> void +CheatEngine.Client.Processes.LocalProcessSnapshot.LocalProcessSnapshot(CheatEngine.Client.Processes.LocalProcessId id, string? name, string? executablePath) -> void +CheatEngine.Client.Processes.LocalProcessSnapshot.Name.get -> string? +CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.ProcessSnapshot.Architecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Processes.ProcessSnapshot.Backend.get -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.Client.Processes.ProcessSnapshot.Bitness.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Processes.ProcessSnapshot.ConfiguredPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Processes.ProcessSnapshot.ConfiguredPointerSizeBytes.get -> int? +CheatEngine.Client.Processes.ProcessSnapshot.ConfiguredPointerSizeDiffersFromBitness.get -> bool? +CheatEngine.Client.Processes.ProcessSnapshot.Equals(CheatEngine.Client.Processes.ProcessSnapshot other) -> bool +CheatEngine.Client.Processes.ProcessSnapshot.ExecutablePath.get -> string? +CheatEngine.Client.Processes.ProcessSnapshot.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.Client.Processes.ProcessSnapshot.Name.get -> string? +CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot() -> void +CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath, CheatEngine.SDK.Engine.Runtime.TargetBackend backend, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture, CheatEngine.SDK.Engine.Runtime.PointerSize bitness, int? configuredPointerSizeBytes, System.DateTimeOffset? startTimeUtc, long selectionEpoch) -> void +CheatEngine.Client.Processes.ProcessSnapshot.SelectionEpoch.get -> long +CheatEngine.Client.Processes.ProcessSnapshot.StartTimeUtc.get -> System.DateTimeOffset? +CheatEngine.Client.Results.CheatEngineActivationExpiredException +CheatEngine.Client.Results.CheatEngineClientException +CheatEngine.Client.Results.CheatEngineClientException.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure +CheatEngine.Client.Results.CheatEngineFailure +CheatEngine.Client.Results.CheatEngineFailure.CheatEngineFailure() -> void +CheatEngine.Client.Results.CheatEngineFailure.CheatEngineFailure(CheatEngine.Client.Results.CheatEngineFailureKind kind, string! operation, string! message, System.Exception? exception = null, CheatEngine.Client.Results.CheatEngineHostEffect hostEffect = CheatEngine.Client.Results.CheatEngineHostEffect.Unknown) -> void +CheatEngine.Client.Results.CheatEngineFailure.Equals(CheatEngine.Client.Results.CheatEngineFailure other) -> bool +CheatEngine.Client.Results.CheatEngineFailure.Exception.get -> System.Exception? +CheatEngine.Client.Results.CheatEngineFailure.HostEffect.get -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineFailure.IsDefault.get -> bool +CheatEngine.Client.Results.CheatEngineFailure.Kind.get -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailure.Message.get -> string! +CheatEngine.Client.Results.CheatEngineFailure.Operation.get -> string! +CheatEngine.Client.Results.CheatEngineFailure.Throw(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Results.CheatEngineFailure.ToException(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Exception! +CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.ActivationExpired = 14 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.AmbiguousMatch = 5 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.BindingError = 8 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.Cancelled = 1 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.CapabilityUnavailable = 2 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.IndeterminateHostResult = 16 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.InvalidHostResult = 9 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.InvalidState = 15 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.LuaError = 7 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.MemoryReadFailed = 12 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.MemoryWriteFailed = 13 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.NotFound = 4 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.OperationRejected = 3 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.ResultLimitExceeded = 6 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.RuntimeChanged = 19 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.TargetChanged = 17 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.TargetIdentityUnavailable = 18 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.TargetNotAttached = 11 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.Unknown = 0 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.Unsupported = 10 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineHostEffect.CleanupUnconfirmed = 4 -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineHostEffect.Completed = 3 -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineHostEffect.NotApplied = 5 -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineHostEffect.NotStarted = 1 -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineHostEffect.Started = 2 -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineHostEffect.Unknown = 0 -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.CheatEngineInvalidStateException +CheatEngine.Client.Results.CheatEngineOperationCanceledException +CheatEngine.Client.Results.CheatEngineOperationCanceledException.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure +CheatEngine.Client.Results.CheatEngineOperationException +CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.AlreadyReleased = 2 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.CleanupUnavailable = 12 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.CleanupUnconfirmed = 11 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.ExternallyRemoved = 6 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.PartiallyReleased = 3 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.RefusedRuntimeChanged = 10 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.RefusedTargetChanged = 8 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.RefusedTargetIdentityUnavailable = 9 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.RefusedTargetNotAttached = 7 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.Released = 1 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.Replaced = 4 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.Superseded = 5 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseKind.Unknown = 0 -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseOutcome +CheatEngine.Client.Results.LeaseReleaseOutcome.Equals(CheatEngine.Client.Results.LeaseReleaseOutcome other) -> bool +CheatEngine.Client.Results.LeaseReleaseOutcome.HostEffect.get -> CheatEngine.Client.Results.CheatEngineHostEffect +CheatEngine.Client.Results.LeaseReleaseOutcome.IsComplete.get -> bool +CheatEngine.Client.Results.LeaseReleaseOutcome.IsRetryable.get -> bool +CheatEngine.Client.Results.LeaseReleaseOutcome.Kind.get -> CheatEngine.Client.Results.LeaseReleaseKind +CheatEngine.Client.Results.LeaseReleaseOutcome.LeaseReleaseOutcome() -> void +CheatEngine.Client.Results.LeaseReleaseOutcome.LeaseReleaseOutcome(CheatEngine.Client.Results.LeaseReleaseKind kind, CheatEngine.Client.Results.CheatEngineHostEffect hostEffect) -> void +CheatEngine.Client.Results.LeaseReleaseOutcome.RequiresManualRecovery.get -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineBitness.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineRuntimePlatformInfo() -> void +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineRuntimePlatformInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem hostOperatingSystem, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture hostArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize cheatEngineBitness, CheatEngine.SDK.Engine.Runtime.TargetBackend targetBackend, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize targetBitness, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi, bool? targetIsAndroid, int? configuredPointerSizeBytes) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.ConfiguredPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.ConfiguredPointerSizeBytes.get -> int? +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.ConfiguredPointerSizeDiffersFromBitness.get -> bool? +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo other) -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.HostArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.HostOperatingSystem.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetBackend.get -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetBitness.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetIsAndroid.get -> bool? +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Capabilities.get -> CheatEngine.Client.Runtime.ClientCapabilities! +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot() -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot(long epoch, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo version, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo platform, CheatEngine.Client.Runtime.ClientCapabilities! capabilities) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Epoch.get -> long +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot other) -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Platform.get -> CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Version.get -> CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineRuntimeVersionInfo() -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineRuntimeVersionInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? cheatEngineVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion qualifiedCheatEngineBaseline, System.Version! clientAssemblyVersion, System.Version! sdkAssemblyVersion, string? sdkPackageVersion, bool isReviewedSdkPackage) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineVersion.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ClientAssemblyVersion.get -> System.Version! +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo other) -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.IsOnQualifiedCheatEngineLine.get -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.IsReviewedSdkPackage.get -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.QualifiedCheatEngineBaseline.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.SdkAssemblyVersion.get -> System.Version! +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.SdkPackageVersion.get -> string? +CheatEngine.Client.Runtime.ClientCapabilities +CheatEngine.Client.Runtime.ClientCapabilities.Count.get -> int +CheatEngine.Client.Runtime.ClientCapabilities.Entries.get -> System.ReadOnlySpan +CheatEngine.Client.Runtime.ClientCapabilities.Equals(CheatEngine.Client.Runtime.ClientCapabilities? other) -> bool +CheatEngine.Client.Runtime.ClientCapabilities.TryGet(CheatEngine.Client.Runtime.ClientCapabilityId capability, out CheatEngine.Client.Runtime.ClientCapabilityAvailability availability) -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability +CheatEngine.Client.Runtime.ClientCapabilityAvailability.Capability.get -> CheatEngine.Client.Runtime.ClientCapabilityId +CheatEngine.Client.Runtime.ClientCapabilityAvailability.ClientCapabilityAvailability() -> void CheatEngine.Client.Runtime.ClientCapabilityAvailability.ClientCapabilityAvailability(CheatEngine.Client.Runtime.ClientCapabilityId capability, CheatEngine.Client.Runtime.ClientCapabilityEvidence evidence) -> void +CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(CheatEngine.Client.Runtime.ClientCapabilityAvailability other) -> bool CheatEngine.Client.Runtime.ClientCapabilityAvailability.Evidence.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidence +CheatEngine.Client.Runtime.ClientCapabilityAvailability.IsAvailable.get -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability.IsKnown.get -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability.Reason.get -> string! +CheatEngine.Client.Runtime.ClientCapabilityAvailability.State.get -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Available = 1 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Unavailable = 2 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Unknown = 0 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState CheatEngine.Client.Runtime.ClientCapabilityEvidence +CheatEngine.Client.Runtime.ClientCapabilityEvidence.AvailabilityState.get -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState CheatEngine.Client.Runtime.ClientCapabilityEvidence.ClientCapabilityEvidence() -> void CheatEngine.Client.Runtime.ClientCapabilityEvidence.ClientCapabilityEvidence(CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate implementation, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate package, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate host, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate liveQualification, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate policy, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate lifetime) -> void -CheatEngine.Client.Runtime.ClientCapabilityEvidence.AvailabilityState.get -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState CheatEngine.Client.Runtime.ClientCapabilityEvidence.EffectiveReason.get -> string! CheatEngine.Client.Runtime.ClientCapabilityEvidence.EffectiveReasonCode.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidence.Equals(CheatEngine.Client.Runtime.ClientCapabilityEvidence other) -> bool CheatEngine.Client.Runtime.ClientCapabilityEvidence.Host.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate CheatEngine.Client.Runtime.ClientCapabilityEvidence.Implementation.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate -CheatEngine.Client.Runtime.ClientCapabilityEvidence.IsExecutable.get -> bool CheatEngine.Client.Runtime.ClientCapabilityEvidence.Lifetime.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate CheatEngine.Client.Runtime.ClientCapabilityEvidence.LiveQualification.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate CheatEngine.Client.Runtime.ClientCapabilityEvidence.Package.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate CheatEngine.Client.Runtime.ClientCapabilityEvidence.Policy.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate -CheatEngine.Client.Runtime.ClientCapabilityEvidence.Equals(CheatEngine.Client.Runtime.ClientCapabilityEvidence other) -> bool CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.ClientCapabilityEvidenceGate() -> void CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.ClientCapabilityEvidenceGate(CheatEngine.Client.Runtime.ClientCapabilityEvidenceState state, string! reason) -> void +CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.Equals(CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate other) -> bool CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.IsSatisfied.get -> bool CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.Reason.get -> string! CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.State.get -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceState -CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.Equals(CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate other) -> bool CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode -CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Host = 2 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode -CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Implementation = 0 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode -CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Lifetime = 5 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode -CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.LiveQualification = 3 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode -CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Package = 1 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode -CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Policy = 4 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Host = 3 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Implementation = 1 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Lifetime = 6 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.LiveQualification = 4 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Package = 2 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Policy = 5 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode +CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode.Unknown = 0 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode CheatEngine.Client.Runtime.ClientCapabilityEvidenceState CheatEngine.Client.Runtime.ClientCapabilityEvidenceState.Faulted = 3 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceState CheatEngine.Client.Runtime.ClientCapabilityEvidenceState.Malformed = 4 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceState CheatEngine.Client.Runtime.ClientCapabilityEvidenceState.Missing = 2 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceState CheatEngine.Client.Runtime.ClientCapabilityEvidenceState.Satisfied = 1 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceState CheatEngine.Client.Runtime.ClientCapabilityEvidenceState.Unknown = 0 -> CheatEngine.Client.Runtime.ClientCapabilityEvidenceState +CheatEngine.Client.Runtime.ClientCapabilityId +CheatEngine.Client.Runtime.ClientCapabilityId.ClientCapabilityId() -> void +CheatEngine.Client.Runtime.ClientCapabilityId.ClientCapabilityId(string! value) -> void +CheatEngine.Client.Runtime.ClientCapabilityId.Equals(CheatEngine.Client.Runtime.ClientCapabilityId other) -> bool +CheatEngine.Client.Runtime.ClientCapabilityId.IsEmpty.get -> bool +CheatEngine.Client.Runtime.ClientCapabilityId.Value.get -> string! +CheatEngine.Client.Runtime.ICheatEngineRuntime +CheatEngine.Client.Runtime.ICheatEngineRuntime.Epoch.get -> long +CheatEngine.Client.Runtime.ICheatEngineRuntime.GetClientCapability(CheatEngine.Client.Runtime.ClientCapabilityId capability, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Runtime.ClientCapabilityAvailability +CheatEngine.Client.Runtime.ICheatEngineRuntime.GetSnapshot(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot +CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetClientCapability(CheatEngine.Client.Runtime.ClientCapabilityId capability, out CheatEngine.Client.Runtime.ClientCapabilityAvailability availability, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetSnapshot(out CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.AobPattern +CheatEngine.Client.Scanning.AobPattern.AobPattern() -> void +CheatEngine.Client.Scanning.AobPattern.AobPattern(string! value) -> void +CheatEngine.Client.Scanning.AobPattern.ByteLength.get -> int +CheatEngine.Client.Scanning.AobPattern.Equals(CheatEngine.Client.Scanning.AobPattern other) -> bool +CheatEngine.Client.Scanning.AobPattern.IsWildcardOnly.get -> bool +CheatEngine.Client.Scanning.AobPattern.Value.get -> string! +CheatEngine.Client.Scanning.AobScanRange +CheatEngine.Client.Scanning.AobScanRange.AobScanRange() -> void +CheatEngine.Client.Scanning.AobScanRange.AobScanRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> void +CheatEngine.Client.Scanning.AobScanRange.Contains(CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.Client.Scanning.AobScanRange.End.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.AobScanRange.Equals(CheatEngine.Client.Scanning.AobScanRange other) -> bool +CheatEngine.Client.Scanning.AobScanRange.Start.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.AobScanRequest +CheatEngine.Client.Scanning.AobScanRequest.Alignment.get -> CheatEngine.Client.Scanning.ScanAlignment +CheatEngine.Client.Scanning.AobScanRequest.AobScanRequest() -> void +CheatEngine.Client.Scanning.AobScanRequest.AobScanRequest(CheatEngine.Client.Scanning.AobPattern pattern, int maximumResults, CheatEngine.SDK.Engine.Inspection.ModuleName? module = null, CheatEngine.Client.Scanning.AobScanRange? range = null, CheatEngine.Client.Scanning.ScanProtectionFilter protection = default(CheatEngine.Client.Scanning.ScanProtectionFilter), CheatEngine.Client.Scanning.ScanAlignment alignment = default(CheatEngine.Client.Scanning.ScanAlignment)) -> void +CheatEngine.Client.Scanning.AobScanRequest.Equals(CheatEngine.Client.Scanning.AobScanRequest other) -> bool +CheatEngine.Client.Scanning.AobScanRequest.MaximumResults.get -> int +CheatEngine.Client.Scanning.AobScanRequest.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName? +CheatEngine.Client.Scanning.AobScanRequest.Pattern.get -> CheatEngine.Client.Scanning.AobPattern +CheatEngine.Client.Scanning.AobScanRequest.Protection.get -> CheatEngine.Client.Scanning.ScanProtectionFilter +CheatEngine.Client.Scanning.AobScanRequest.Range.get -> CheatEngine.Client.Scanning.AobScanRange? +CheatEngine.Client.Scanning.AobScanResult +CheatEngine.Client.Scanning.AobScanResult.AobScanResult() -> void +CheatEngine.Client.Scanning.AobScanResult.AobScanResult(System.Collections.Immutable.ImmutableArray matches, bool isTruncated) -> void +CheatEngine.Client.Scanning.AobScanResult.IsTruncated.get -> bool +CheatEngine.Client.Scanning.AobScanResult.Matches.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Scanning.IPatternScanner +CheatEngine.Client.Scanning.IPatternScanner.Scan(CheatEngine.Client.Scanning.AobScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult +CheatEngine.Client.Scanning.IPatternScanner.ScanDetailed(CheatEngine.Client.Scanning.AobScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.PatternScanOutcome! +CheatEngine.Client.Scanning.IPatternScanner.TryScan(CheatEngine.Client.Scanning.AobScanRequest request, out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.Cancelled = 12 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.GlobalUnavailable = 4 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.HostReportedError = 8 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.InvalidResult = 6 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.Matches = 1 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.NoMatches = 2 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.NoResult = 3 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.ProtectedLuaFailure = 5 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.ResultListCountUnavailable = 7 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.RuntimeChanged = 11 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.TargetChanged = 9 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.TargetIdentityUnavailable = 10 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanHostOutcomeKind.Unknown = 0 -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanMetrics +CheatEngine.Client.Scanning.PatternScanMetrics.AtOrAfterStopSkippedCount.get -> ulong +CheatEngine.Client.Scanning.PatternScanMetrics.BelowStartSkippedCount.get -> ulong +CheatEngine.Client.Scanning.PatternScanMetrics.Equals(CheatEngine.Client.Scanning.PatternScanMetrics other) -> bool +CheatEngine.Client.Scanning.PatternScanMetrics.ExaminedCount.get -> ulong +CheatEngine.Client.Scanning.PatternScanMetrics.FilteredOutCount.get -> ulong +CheatEngine.Client.Scanning.PatternScanMetrics.HostResultCount.get -> ulong +CheatEngine.Client.Scanning.PatternScanMetrics.HostScanElapsed.get -> System.TimeSpan +CheatEngine.Client.Scanning.PatternScanMetrics.InBoundsCountIsExact.get -> bool +CheatEngine.Client.Scanning.PatternScanMetrics.MaterializationElapsed.get -> System.TimeSpan +CheatEngine.Client.Scanning.PatternScanMetrics.MaterializedCount.get -> int +CheatEngine.Client.Scanning.PatternScanMetrics.PatternScanMetrics() -> void +CheatEngine.Client.Scanning.PatternScanMetrics.PatternScanMetrics(CheatEngine.Client.Scanning.PatternScanScope scope, ulong hostResultCount, ulong examinedCount, ulong filteredOutCount, int materializedCount, ulong belowStartSkippedCount, ulong atOrAfterStopSkippedCount, ulong unreadHostRowCount, bool inBoundsCountIsExact, System.TimeSpan hostScanElapsed, System.TimeSpan materializationElapsed) -> void +CheatEngine.Client.Scanning.PatternScanMetrics.Scope.get -> CheatEngine.Client.Scanning.PatternScanScope +CheatEngine.Client.Scanning.PatternScanMetrics.UnreadHostRowCount.get -> ulong +CheatEngine.Client.Scanning.PatternScanOutcome +CheatEngine.Client.Scanning.PatternScanOutcome.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure? +CheatEngine.Client.Scanning.PatternScanOutcome.HostOutcome.get -> CheatEngine.Client.Scanning.PatternScanHostOutcomeKind +CheatEngine.Client.Scanning.PatternScanOutcome.IsSuccess.get -> bool +CheatEngine.Client.Scanning.PatternScanOutcome.Metrics.get -> CheatEngine.Client.Scanning.PatternScanMetrics? +CheatEngine.Client.Scanning.PatternScanOutcome.PatternScanOutcome(CheatEngine.Client.Scanning.AobScanResult? result, CheatEngine.Client.Results.CheatEngineFailure? failure, CheatEngine.Client.Scanning.PatternScanMetrics? metrics, CheatEngine.Client.Scanning.PatternScanHostOutcomeKind hostOutcome, CheatEngine.Client.Scanning.PatternScanRouteReason routeReason, bool targetIdentityVerified) -> void +CheatEngine.Client.Scanning.PatternScanOutcome.Result.get -> CheatEngine.Client.Scanning.AobScanResult? +CheatEngine.Client.Scanning.PatternScanOutcome.RouteReason.get -> CheatEngine.Client.Scanning.PatternScanRouteReason +CheatEngine.Client.Scanning.PatternScanOutcome.TargetIdentityVerified.get -> bool +CheatEngine.Client.Scanning.PatternScanRouteReason +CheatEngine.Client.Scanning.PatternScanRouteReason.ScopedRequestOnQualifiedTarget = 2 -> CheatEngine.Client.Scanning.PatternScanRouteReason +CheatEngine.Client.Scanning.PatternScanRouteReason.TargetIdentityNotQualified = 3 -> CheatEngine.Client.Scanning.PatternScanRouteReason +CheatEngine.Client.Scanning.PatternScanRouteReason.Unknown = 0 -> CheatEngine.Client.Scanning.PatternScanRouteReason +CheatEngine.Client.Scanning.PatternScanRouteReason.UnscopedRequest = 1 -> CheatEngine.Client.Scanning.PatternScanRouteReason +CheatEngine.Client.Scanning.PatternScanScope +CheatEngine.Client.Scanning.PatternScanScope.GlobalHostScan = 1 -> CheatEngine.Client.Scanning.PatternScanScope +CheatEngine.Client.Scanning.PatternScanScope.GlobalHostScanWithManagedFilter = 3 -> CheatEngine.Client.Scanning.PatternScanScope +CheatEngine.Client.Scanning.PatternScanScope.HostBoundedRange = 2 -> CheatEngine.Client.Scanning.PatternScanScope +CheatEngine.Client.Scanning.PatternScanScope.Unknown = 0 -> CheatEngine.Client.Scanning.PatternScanScope +CheatEngine.Client.Scanning.ScanAlignment +CheatEngine.Client.Scanning.ScanAlignment.Digits.get -> string? +CheatEngine.Client.Scanning.ScanAlignment.Divisor.get -> int +CheatEngine.Client.Scanning.ScanAlignment.Equals(CheatEngine.Client.Scanning.ScanAlignment other) -> bool +CheatEngine.Client.Scanning.ScanAlignment.Mode.get -> CheatEngine.Client.Scanning.ScanAlignmentMode +CheatEngine.Client.Scanning.ScanAlignment.ScanAlignment() -> void +CheatEngine.Client.Scanning.ScanAlignmentMode +CheatEngine.Client.Scanning.ScanAlignmentMode.AlignedTo = 1 -> CheatEngine.Client.Scanning.ScanAlignmentMode +CheatEngine.Client.Scanning.ScanAlignmentMode.LastDigits = 2 -> CheatEngine.Client.Scanning.ScanAlignmentMode +CheatEngine.Client.Scanning.ScanAlignmentMode.None = 0 -> CheatEngine.Client.Scanning.ScanAlignmentMode +CheatEngine.Client.Scanning.ScanProtectionFilter +CheatEngine.Client.Scanning.ScanProtectionFilter.CopyOnWrite.get -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionFilter.Equals(CheatEngine.Client.Scanning.ScanProtectionFilter other) -> bool +CheatEngine.Client.Scanning.ScanProtectionFilter.Executable.get -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionFilter.IsUnspecified.get -> bool +CheatEngine.Client.Scanning.ScanProtectionFilter.ScanProtectionFilter() -> void +CheatEngine.Client.Scanning.ScanProtectionFilter.ScanProtectionFilter(CheatEngine.Client.Scanning.ScanProtectionRequirement executable, CheatEngine.Client.Scanning.ScanProtectionRequirement copyOnWrite, CheatEngine.Client.Scanning.ScanProtectionRequirement writable) -> void +CheatEngine.Client.Scanning.ScanProtectionFilter.Writable.get -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionRequirement.Any = 3 -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionRequirement.Excluded = 2 -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionRequirement.Required = 1 -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Scanning.ScanProtectionRequirement.Unspecified = 0 -> CheatEngine.Client.Scanning.ScanProtectionRequirement +CheatEngine.Client.Tables.AddressTableSnapshot +CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot() -> void +CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot(System.Collections.Immutable.ImmutableArray records) -> void +CheatEngine.Client.Tables.AddressTableSnapshot.RecordCount.get -> int +CheatEngine.Client.Tables.AddressTableSnapshot.Records.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Tables.ITableClient +CheatEngine.Client.Tables.ITableClient.Create(CheatEngine.Client.Tables.MemoryRecordDefinition definition, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.Delete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Tables.ITableClient.Find(CheatEngine.Client.Tables.MemoryRecordSearch search, CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Tables.ITableClient.GetHierarchy(CheatEngine.SDK.Engine.AddressList.MemoryRecordId rootId, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot +CheatEngine.Client.Tables.ITableClient.GetRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.GetRecordAt(int index, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.GetRecordCount(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> int +CheatEngine.Client.Tables.ITableClient.GetSelectedRecord(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.GetSnapshot(CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.AddressTableSnapshot +CheatEngine.Client.Tables.ITableClient.LoadTrustedTable(CheatEngine.Client.Tables.TableLoadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Tables.ITableClient.SaveTable(CheatEngine.Client.Tables.TableSaveRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Tables.ITableClient.SelectRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.SetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, bool isActive, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId childId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.TryCreate(CheatEngine.Client.Tables.MemoryRecordDefinition definition, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryDelete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryFind(CheatEngine.Client.Tables.MemoryRecordSearch search, CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, out System.Collections.Immutable.ImmutableArray records, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetHierarchy(CheatEngine.SDK.Engine.AddressList.MemoryRecordId rootId, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest request, out CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot hierarchy, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetRecordAt(int index, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetRecordCount(out int recordCount, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetSelectedRecord(out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetSnapshot(CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, out CheatEngine.Client.Tables.AddressTableSnapshot table, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryLoadTrustedTable(CheatEngine.Client.Tables.TableLoadRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySaveTable(CheatEngine.Client.Tables.TableSaveRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySelectRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, bool isActive, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId childId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryUpdate(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, CheatEngine.Client.Tables.MemoryRecordUpdate update, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.Update(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, CheatEngine.Client.Tables.MemoryRecordUpdate update, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.MemoryRecordCollectionRequest +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.Equals(CheatEngine.Client.Tables.MemoryRecordCollectionRequest other) -> bool +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MaximumItems.get -> int +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MemoryRecordCollectionRequest() -> void +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MemoryRecordCollectionRequest(int maximumItems) -> void +CheatEngine.Client.Tables.MemoryRecordContentSnapshot +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.AddressExpression.get -> string! +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Description.get -> string! +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordContentSnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.MemoryRecordContentSnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.MemoryRecordContentSnapshot(string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType, string? script = null, int offsetCount = 0) -> void +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.OffsetCount.get -> int +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Script.get -> string? +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Value.get -> string! +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.Client.Tables.MemoryRecordDefinition +CheatEngine.Client.Tables.MemoryRecordDefinition.AddressExpression.get -> string! +CheatEngine.Client.Tables.MemoryRecordDefinition.Description.get -> string! +CheatEngine.Client.Tables.MemoryRecordDefinition.Equals(CheatEngine.Client.Tables.MemoryRecordDefinition other) -> bool +CheatEngine.Client.Tables.MemoryRecordDefinition.MemoryRecordDefinition() -> void +CheatEngine.Client.Tables.MemoryRecordDefinition.MemoryRecordDefinition(string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId = null) -> void +CheatEngine.Client.Tables.MemoryRecordDefinition.ParentId.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId? +CheatEngine.Client.Tables.MemoryRecordDefinition.Value.get -> string! +CheatEngine.Client.Tables.MemoryRecordDefinition.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.Equals(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest other) -> bool +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MaximumDepth.get -> int +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MaximumItems.get -> int +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MemoryRecordHierarchyRequest() -> void +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MemoryRecordHierarchyRequest(int maximumItems, int maximumDepth) -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Children.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.MemoryRecordHierarchySnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.MemoryRecordHierarchySnapshot(CheatEngine.Client.Tables.MemoryRecordSnapshot record, System.Collections.Immutable.ImmutableArray children) -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Record.get -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.MemoryRecordSearch +CheatEngine.Client.Tables.MemoryRecordSearch.AddressExpression.get -> string? +CheatEngine.Client.Tables.MemoryRecordSearch.DescriptionContains.get -> string? +CheatEngine.Client.Tables.MemoryRecordSearch.Equals(CheatEngine.Client.Tables.MemoryRecordSearch other) -> bool +CheatEngine.Client.Tables.MemoryRecordSearch.IsActive.get -> bool? +CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch() -> void +CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch(string? descriptionContains = null, string? addressExpression = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null, bool? isActive = null) -> void +CheatEngine.Client.Tables.MemoryRecordSearch.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? +CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.MemoryRecordSnapshot.Content.get -> CheatEngine.Client.Tables.MemoryRecordContentSnapshot +CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordSnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordSnapshot.Id.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId +CheatEngine.Client.Tables.MemoryRecordSnapshot.Index.get -> int +CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, int index, CheatEngine.Client.Tables.MemoryRecordContentSnapshot content, CheatEngine.Client.Tables.MemoryRecordStateSnapshot state) -> void +CheatEngine.Client.Tables.MemoryRecordSnapshot.State.get -> CheatEngine.Client.Tables.MemoryRecordStateSnapshot +CheatEngine.Client.Tables.MemoryRecordStateSnapshot +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.ChildCount.get -> int +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.CurrentAddress.get -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordStateSnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.IsActive.get -> bool +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.IsAsync.get -> bool +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.IsAsyncProcessing.get -> bool +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.MemoryRecordStateSnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.MemoryRecordStateSnapshot(CheatEngine.SDK.Engine.Values.Address? currentAddress, bool isActive = false, int childCount = 0, bool isAsync = false, bool isAsyncProcessing = false) -> void +CheatEngine.Client.Tables.MemoryRecordUpdate +CheatEngine.Client.Tables.MemoryRecordUpdate.AddressExpression.get -> string? +CheatEngine.Client.Tables.MemoryRecordUpdate.Description.get -> string? +CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(CheatEngine.Client.Tables.MemoryRecordUpdate other) -> bool +CheatEngine.Client.Tables.MemoryRecordUpdate.MemoryRecordUpdate() -> void +CheatEngine.Client.Tables.MemoryRecordUpdate.MemoryRecordUpdate(string? description = null, string? addressExpression = null, string? value = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null) -> void +CheatEngine.Client.Tables.MemoryRecordUpdate.Value.get -> string? +CheatEngine.Client.Tables.MemoryRecordUpdate.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? +CheatEngine.Client.Tables.TableLoadRequest +CheatEngine.Client.Tables.TableLoadRequest.Equals(CheatEngine.Client.Tables.TableLoadRequest other) -> bool +CheatEngine.Client.Tables.TableLoadRequest.File.get -> CheatEngine.Client.Tables.TrustedTableFile +CheatEngine.Client.Tables.TableLoadRequest.Merge.get -> bool +CheatEngine.Client.Tables.TableLoadRequest.TableLoadRequest() -> void +CheatEngine.Client.Tables.TableLoadRequest.TableLoadRequest(CheatEngine.Client.Tables.TrustedTableFile file, bool merge = false) -> void +CheatEngine.Client.Tables.TableSaveRequest +CheatEngine.Client.Tables.TableSaveRequest.Equals(CheatEngine.Client.Tables.TableSaveRequest other) -> bool +CheatEngine.Client.Tables.TableSaveRequest.File.get -> CheatEngine.Client.Tables.TrustedTableFile +CheatEngine.Client.Tables.TableSaveRequest.TableSaveRequest() -> void +CheatEngine.Client.Tables.TableSaveRequest.TableSaveRequest(CheatEngine.Client.Tables.TrustedTableFile file) -> void +CheatEngine.Client.Tables.TrustedTableFile +CheatEngine.Client.Tables.TrustedTableFile.Equals(CheatEngine.Client.Tables.TrustedTableFile other) -> bool +CheatEngine.Client.Tables.TrustedTableFile.FullPath.get -> string! +CheatEngine.Client.Tables.TrustedTableFile.TrustedTableFile() -> void +CheatEngine.Client.Tables.TrustedTableFile.TrustedTableFile(string! path) -> void +[CECLIENT5001]CheatEngine.Client.ICheatEngineClient.ValueScans.get -> CheatEngine.Client.Scanning.IValueScanner! +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.FirstScan(CheatEngine.Client.Scanning.ValueScanFirstRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.GetResultCount(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ulong +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.Invalidation.get -> CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.NextScan(CheatEngine.Client.Scanning.ValueScanNextRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.Read(CheatEngine.Client.Scanning.ValueScanReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.ValueScanPage +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.Reset(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.SelectionEpoch.get -> long +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.State.get -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.TryFirstScan(CheatEngine.Client.Scanning.ValueScanFirstRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.TryGetResultCount(out ulong resultCount, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.TryNextScan(CheatEngine.Client.Scanning.ValueScanNextRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.TryRead(CheatEngine.Client.Scanning.ValueScanReadRequest request, out CheatEngine.Client.Scanning.ValueScanPage page, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanSession.TryReset(out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanner +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanner.CreateSession(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.IValueScanSession! +[CECLIENT5001]CheatEngine.Client.Scanning.IValueScanner.TryCreateSession(out CheatEngine.Client.Scanning.IValueScanSession? session, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.Between = 1 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.BiggerThan = 2 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.Changed = 9 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.Decreased = 7 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.DecreasedBy = 8 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.Exact = 0 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.Increased = 5 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.IncreasedBy = 6 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.SmallerThan = 3 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.Unchanged = 10 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanComparison.UnknownInitialValue = 4 -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.Alignment.get -> CheatEngine.Client.Scanning.ScanAlignment +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.Comparison.get -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.Equals(CheatEngine.Client.Scanning.ValueScanFirstRequest other) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.Protection.get -> CheatEngine.Client.Scanning.ScanProtectionFilter +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.StartAddress.get -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.StopAddress.get -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.UpperValue.get -> CheatEngine.Client.Scanning.ValueScanValue? +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.Value.get -> CheatEngine.Client.Scanning.ValueScanValue? +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.ValueScanFirstRequest() -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.ValueType.get -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.WithAlignment(CheatEngine.Client.Scanning.ScanAlignment alignment) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.WithProtection(CheatEngine.Client.Scanning.ScanProtectionFilter protection) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanFirstRequest.WithRange(CheatEngine.SDK.Engine.Values.Address startAddress, CheatEngine.SDK.Engine.Values.Address stopAddress) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanInvalidationKind.HostCallFailed = 2 -> CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanInvalidationKind.None = 1 -> CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanInvalidationKind.RuntimeChanged = 3 -> CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanInvalidationKind.TargetChanged = 4 -> CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanInvalidationKind.Unknown = 0 -> CheatEngine.Client.Scanning.ValueScanInvalidationKind +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanMatch +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanMatch.Address.get -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanMatch.Equals(CheatEngine.Client.Scanning.ValueScanMatch other) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanMatch.ValueScanMatch() -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanMatch.ValueScanMatch(CheatEngine.SDK.Engine.Values.Address address, string! valueText) -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanMatch.ValueText.get -> string! +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanNextRequest.Comparison.get -> CheatEngine.Client.Scanning.ValueScanComparison +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanNextRequest.Equals(CheatEngine.Client.Scanning.ValueScanNextRequest other) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanNextRequest.UpperValue.get -> CheatEngine.Client.Scanning.ValueScanValue? +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanNextRequest.Value.get -> CheatEngine.Client.Scanning.ValueScanValue? +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanNextRequest.ValueScanNextRequest() -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.HasMore.get -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.Matches.get -> System.Collections.Immutable.ImmutableArray +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.NextStartIndex.get -> long +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.ResultCount.get -> ulong +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.StartIndex.get -> long +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.ValueScanPage() -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanPage.ValueScanPage(long startIndex, ulong resultCount, System.Collections.Immutable.ImmutableArray matches) -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanReadRequest +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanReadRequest.Equals(CheatEngine.Client.Scanning.ValueScanReadRequest other) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanReadRequest.MaximumCount.get -> int +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanReadRequest.StartIndex.get -> long +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanReadRequest.ValueScanReadRequest() -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanReadRequest.ValueScanReadRequest(long startIndex, int maximumCount) -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState.Closed = 5 -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState.Created = 1 -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState.Invalidated = 4 -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState.ResultsReady = 3 -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState.Scanning = 2 -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanSessionState.Unknown = 0 -> CheatEngine.Client.Scanning.ValueScanSessionState +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValue.Equals(CheatEngine.Client.Scanning.ValueScanValue other) -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValue.IsNumeric.get -> bool +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValue.Text.get -> string? +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValue.ValueScanValue() -> void +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValue.ValueType.get -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.ByteArray = 8 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.DoubleFloat = 5 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.Integer16 = 1 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.Integer32 = 2 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.Integer64 = 3 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.Integer8 = 0 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.SingleFloat = 4 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.Utf16String = 7 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]CheatEngine.Client.Scanning.ValueScanValueType.Utf8String = 6 -> CheatEngine.Client.Scanning.ValueScanValueType +[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanFirstRequest.GetHashCode() -> int +[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanMatch.GetHashCode() -> int +[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanNextRequest.GetHashCode() -> int +[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanReadRequest.GetHashCode() -> int +[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanValue.GetHashCode() -> int +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.Between(CheatEngine.Client.Scanning.ValueScanValue lowest, CheatEngine.Client.Scanning.ValueScanValue highest) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.BiggerThan(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.Exact(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.SmallerThan(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.UnknownInitialValue(CheatEngine.Client.Scanning.ValueScanValueType valueType) -> CheatEngine.Client.Scanning.ValueScanFirstRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.operator !=(CheatEngine.Client.Scanning.ValueScanFirstRequest left, CheatEngine.Client.Scanning.ValueScanFirstRequest right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanFirstRequest.operator ==(CheatEngine.Client.Scanning.ValueScanFirstRequest left, CheatEngine.Client.Scanning.ValueScanFirstRequest right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanMatch.operator !=(CheatEngine.Client.Scanning.ValueScanMatch left, CheatEngine.Client.Scanning.ValueScanMatch right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanMatch.operator ==(CheatEngine.Client.Scanning.ValueScanMatch left, CheatEngine.Client.Scanning.ValueScanMatch right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.Between(CheatEngine.Client.Scanning.ValueScanValue lowest, CheatEngine.Client.Scanning.ValueScanValue highest) -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.BiggerThan(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.Changed() -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.Decreased() -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.DecreasedBy(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.Exact(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.Increased() -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.IncreasedBy(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.SmallerThan(CheatEngine.Client.Scanning.ValueScanValue value) -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.Unchanged() -> CheatEngine.Client.Scanning.ValueScanNextRequest +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.operator !=(CheatEngine.Client.Scanning.ValueScanNextRequest left, CheatEngine.Client.Scanning.ValueScanNextRequest right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanNextRequest.operator ==(CheatEngine.Client.Scanning.ValueScanNextRequest left, CheatEngine.Client.Scanning.ValueScanNextRequest right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanReadRequest.operator !=(CheatEngine.Client.Scanning.ValueScanReadRequest left, CheatEngine.Client.Scanning.ValueScanReadRequest right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanReadRequest.operator ==(CheatEngine.Client.Scanning.ValueScanReadRequest left, CheatEngine.Client.Scanning.ValueScanReadRequest right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromByte(byte value) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromBytes(System.ReadOnlySpan bytes) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromDouble(double value, int decimals) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromInt16(short value) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromInt32(int value) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromInt64(long value) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromSingle(float value, int decimals) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromUtf16String(string! text) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.FromUtf8String(string! text) -> CheatEngine.Client.Scanning.ValueScanValue +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.operator !=(CheatEngine.Client.Scanning.ValueScanValue left, CheatEngine.Client.Scanning.ValueScanValue right) -> bool +[CECLIENT5001]static CheatEngine.Client.Scanning.ValueScanValue.operator ==(CheatEngine.Client.Scanning.ValueScanValue left, CheatEngine.Client.Scanning.ValueScanValue right) -> bool +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationProtection +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationProtection.ExecuteReadWrite = 1 -> CheatEngine.Client.Allocations.AllocationProtection +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationProtection.ReadWrite = 0 -> CheatEngine.Client.Allocations.AllocationProtection +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest.AllocationRequest() -> void +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest.AllocationRequest(long size, CheatEngine.Client.Allocations.AllocationProtection protection = CheatEngine.Client.Allocations.AllocationProtection.ReadWrite, CheatEngine.SDK.Engine.Values.Address? preferredAddress = null) -> void +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest.Equals(CheatEngine.Client.Allocations.AllocationRequest other) -> bool +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest.PreferredAddress.get -> CheatEngine.SDK.Engine.Values.Address? +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest.Protection.get -> CheatEngine.Client.Allocations.AllocationProtection +[CECLIENT5002]CheatEngine.Client.Allocations.AllocationRequest.Size.get -> long +[CECLIENT5002]CheatEngine.Client.Allocations.IAllocationClient +[CECLIENT5002]CheatEngine.Client.Allocations.IAllocationClient.Allocate(CheatEngine.Client.Allocations.AllocationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Allocations.ITargetMemoryLease! +[CECLIENT5002]CheatEngine.Client.Allocations.IAllocationClient.TryAllocate(CheatEngine.Client.Allocations.AllocationRequest request, out CheatEngine.Client.Allocations.ITargetMemoryLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5002]CheatEngine.Client.Allocations.ITargetMemoryLease +[CECLIENT5002]CheatEngine.Client.Allocations.ITargetMemoryLease.Address.get -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5002]CheatEngine.Client.Allocations.ITargetMemoryLease.Protection.get -> CheatEngine.Client.Allocations.AllocationProtection +[CECLIENT5002]CheatEngine.Client.Allocations.ITargetMemoryLease.SelectionEpoch.get -> long +[CECLIENT5002]CheatEngine.Client.Allocations.ITargetMemoryLease.Size.get -> long +[CECLIENT5002]CheatEngine.Client.ICheatEngineClient.Allocations.get -> CheatEngine.Client.Allocations.IAllocationClient! +[CECLIENT5002]override CheatEngine.Client.Allocations.AllocationRequest.GetHashCode() -> int +[CECLIENT5002]static CheatEngine.Client.Allocations.AllocationRequest.operator !=(CheatEngine.Client.Allocations.AllocationRequest left, CheatEngine.Client.Allocations.AllocationRequest right) -> bool +[CECLIENT5002]static CheatEngine.Client.Allocations.AllocationRequest.operator ==(CheatEngine.Client.Allocations.AllocationRequest left, CheatEngine.Client.Allocations.AllocationRequest right) -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.AssemblyInstructionRequest() -> void +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.AssemblyInstructionRequest(CheatEngine.SDK.Engine.Values.Address address, string! instruction, CheatEngine.Client.Assembly.InstructionEncodingPreference preference = CheatEngine.Client.Assembly.InstructionEncodingPreference.None, bool skipRangeCheck = false) -> void +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.Equals(CheatEngine.Client.Assembly.AssemblyInstructionRequest other) -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.Instruction.get -> string! +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.Preference.get -> CheatEngine.Client.Assembly.InstructionEncodingPreference +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionRequest.SkipRangeCheck.get -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Address.get -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AddressText.get -> string! +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AssemblyInstructionSnapshot() -> void +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AssemblyInstructionSnapshot(CheatEngine.SDK.Engine.Values.Address address, int length, string! addressText, string! opcode, string! extra, System.ReadOnlySpan bytes) -> void +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Bytes.get -> System.Collections.Immutable.ImmutableArray +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Extra.get -> string! +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Length.get -> int +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Opcode.get -> string! +[CECLIENT5003]CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Text.get -> string! +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.Assemble(CheatEngine.Client.Assembly.AssemblyInstructionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.Disassemble(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.AssemblyInstructionSnapshot +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.GetInstructionLength(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> int +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.GetPreviousInstructionAddress(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.TryAssemble(CheatEngine.Client.Assembly.AssemblyInstructionRequest request, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.TryDisassemble(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Assembly.AssemblyInstructionSnapshot instruction, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.TryGetInstructionLength(CheatEngine.SDK.Engine.Values.Address address, out int length, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.IAssemblyClient.TryGetPreviousInstructionAddress(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address previousAddress, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5003]CheatEngine.Client.Assembly.InstructionEncodingPreference +[CECLIENT5003]CheatEngine.Client.Assembly.InstructionEncodingPreference.Far = 3 -> CheatEngine.Client.Assembly.InstructionEncodingPreference +[CECLIENT5003]CheatEngine.Client.Assembly.InstructionEncodingPreference.Long = 2 -> CheatEngine.Client.Assembly.InstructionEncodingPreference +[CECLIENT5003]CheatEngine.Client.Assembly.InstructionEncodingPreference.None = 0 -> CheatEngine.Client.Assembly.InstructionEncodingPreference +[CECLIENT5003]CheatEngine.Client.Assembly.InstructionEncodingPreference.Short = 1 -> CheatEngine.Client.Assembly.InstructionEncodingPreference +[CECLIENT5003]CheatEngine.Client.ICheatEngineClient.Assembly.get -> CheatEngine.Client.Assembly.IAssemblyClient! +[CECLIENT5003]override CheatEngine.Client.Assembly.AssemblyInstructionRequest.GetHashCode() -> int +[CECLIENT5003]static CheatEngine.Client.Assembly.AssemblyInstructionRequest.operator !=(CheatEngine.Client.Assembly.AssemblyInstructionRequest left, CheatEngine.Client.Assembly.AssemblyInstructionRequest right) -> bool +[CECLIENT5003]static CheatEngine.Client.Assembly.AssemblyInstructionRequest.operator ==(CheatEngine.Client.Assembly.AssemblyInstructionRequest left, CheatEngine.Client.Assembly.AssemblyInstructionRequest right) -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult.AutoAssemblerCheckResult() -> void +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult.AutoAssemblerCheckResult(bool isAccepted, string? hostMessages, bool hostMessagesTruncated) -> void +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult.Equals(CheatEngine.Client.Assembly.AutoAssemblerCheckResult other) -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult.HostMessages.get -> string? +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult.HostMessagesTruncated.get -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerCheckResult.IsAccepted.get -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerScript +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerScript.AutoAssemblerScript() -> void +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerScript.AutoAssemblerScript(string! source, string? name = null) -> void +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerScript.Equals(CheatEngine.Client.Assembly.AutoAssemblerScript other) -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerScript.Name.get -> string? +[CECLIENT5004]CheatEngine.Client.Assembly.AutoAssemblerScript.Source.get -> string! +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerClient +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerClient.ApplyPatch(CheatEngine.Client.Assembly.AutoAssemblerScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.IAutoAssemblerPatchLease! +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerClient.Check(CheatEngine.Client.Assembly.AutoAssemblerScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.AutoAssemblerCheckResult +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerClient.TryApplyPatch(CheatEngine.Client.Assembly.AutoAssemblerScript script, out CheatEngine.Client.Assembly.IAutoAssemblerPatchLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerClient.TryCheck(CheatEngine.Client.Assembly.AutoAssemblerScript script, out CheatEngine.Client.Assembly.AutoAssemblerCheckResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.AppliedAfterTargetChange.get -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.CanDisable.get -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.HostWarnings.get -> string? +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.HostWarningsTruncated.get -> bool +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.Name.get -> string? +[CECLIENT5004]CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.SelectionEpoch.get -> long +[CECLIENT5004]override CheatEngine.Client.Assembly.AutoAssemblerCheckResult.GetHashCode() -> int +[CECLIENT5004]override CheatEngine.Client.Assembly.AutoAssemblerCheckResult.ToString() -> string! +[CECLIENT5004]override CheatEngine.Client.Assembly.AutoAssemblerScript.GetHashCode() -> int +[CECLIENT5004]static CheatEngine.Client.Assembly.AutoAssemblerCheckResult.operator !=(CheatEngine.Client.Assembly.AutoAssemblerCheckResult left, CheatEngine.Client.Assembly.AutoAssemblerCheckResult right) -> bool +[CECLIENT5004]static CheatEngine.Client.Assembly.AutoAssemblerCheckResult.operator ==(CheatEngine.Client.Assembly.AutoAssemblerCheckResult left, CheatEngine.Client.Assembly.AutoAssemblerCheckResult right) -> bool +[CECLIENT5004]static CheatEngine.Client.Assembly.AutoAssemblerScript.operator !=(CheatEngine.Client.Assembly.AutoAssemblerScript left, CheatEngine.Client.Assembly.AutoAssemblerScript right) -> bool +[CECLIENT5004]static CheatEngine.Client.Assembly.AutoAssemblerScript.operator ==(CheatEngine.Client.Assembly.AutoAssemblerScript left, CheatEngine.Client.Assembly.AutoAssemblerScript right) -> bool +const CheatEngine.Client.Memory.MemoryBatchLimits.MaximumOperationCount = 1024 -> int +const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumBatchOperationCount = 1024 -> int +const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumBatchPayloadBytes = 65536 -> int +const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumReadBytes = 1048576 -> int +const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumStringBytes = 65536 -> int +const CheatEngine.Client.Memory.MemoryResourceLimits.DefaultMaximumWriteBytes = 1048576 -> int +override CheatEngine.Client.Inspection.InspectionCollectionRequest.GetHashCode() -> int +override CheatEngine.Client.Inspection.SymbolRegistration.GetHashCode() -> int +override CheatEngine.Client.Lua.LuaExportDescriptor.GetHashCode() -> int +override CheatEngine.Client.Lua.LuaModuleReleaseOutcome.ToString() -> string! +override CheatEngine.Client.Lua.LuaScript.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryBytesReadRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryReadRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryStringReadRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryStringWriteRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryWriteRequest.GetHashCode() -> int +override CheatEngine.Client.Processes.LocalProcessEnumerationRequest.GetHashCode() -> int +override CheatEngine.Client.Processes.LocalProcessId.GetHashCode() -> int +override CheatEngine.Client.Processes.LocalProcessSnapshot.GetHashCode() -> int +override CheatEngine.Client.Processes.ProcessSnapshot.GetHashCode() -> int +override CheatEngine.Client.Results.CheatEngineFailure.GetHashCode() -> int +override CheatEngine.Client.Results.CheatEngineFailure.ToString() -> string! +override CheatEngine.Client.Results.LeaseReleaseOutcome.GetHashCode() -> int +override CheatEngine.Client.Results.LeaseReleaseOutcome.ToString() -> string! +override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.GetHashCode() -> int +override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.GetHashCode() -> int +override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilities.Equals(object? obj) -> bool +override CheatEngine.Client.Runtime.ClientCapabilities.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilityAvailability.GetHashCode() -> int override CheatEngine.Client.Runtime.ClientCapabilityEvidence.GetHashCode() -> int override CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilityId.Equals(object? obj) -> bool +override CheatEngine.Client.Runtime.ClientCapabilityId.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilityId.ToString() -> string! +override CheatEngine.Client.Scanning.AobPattern.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobPattern.ToString() -> string! +override CheatEngine.Client.Scanning.AobScanRange.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobScanRequest.GetHashCode() -> int +override CheatEngine.Client.Scanning.PatternScanMetrics.GetHashCode() -> int +override CheatEngine.Client.Scanning.ScanAlignment.GetHashCode() -> int +override CheatEngine.Client.Scanning.ScanProtectionFilter.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordDefinition.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordSearch.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordSnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordUpdate.GetHashCode() -> int +override CheatEngine.Client.Tables.TableLoadRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.TableSaveRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.TrustedTableFile.GetHashCode() -> int +static CheatEngine.Client.Inspection.InspectionCollectionRequest.operator !=(CheatEngine.Client.Inspection.InspectionCollectionRequest left, CheatEngine.Client.Inspection.InspectionCollectionRequest right) -> bool +static CheatEngine.Client.Inspection.InspectionCollectionRequest.operator ==(CheatEngine.Client.Inspection.InspectionCollectionRequest left, CheatEngine.Client.Inspection.InspectionCollectionRequest right) -> bool +static CheatEngine.Client.Inspection.SymbolRegistration.operator !=(CheatEngine.Client.Inspection.SymbolRegistration left, CheatEngine.Client.Inspection.SymbolRegistration right) -> bool +static CheatEngine.Client.Inspection.SymbolRegistration.operator ==(CheatEngine.Client.Inspection.SymbolRegistration left, CheatEngine.Client.Inspection.SymbolRegistration right) -> bool +static CheatEngine.Client.Lua.LuaExportDescriptor.operator !=(CheatEngine.Client.Lua.LuaExportDescriptor left, CheatEngine.Client.Lua.LuaExportDescriptor right) -> bool +static CheatEngine.Client.Lua.LuaExportDescriptor.operator ==(CheatEngine.Client.Lua.LuaExportDescriptor left, CheatEngine.Client.Lua.LuaExportDescriptor right) -> bool +static CheatEngine.Client.Lua.LuaModuleReleaseOutcome.AlreadyReleased(string! moduleName) -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +static CheatEngine.Client.Lua.LuaModuleReleaseOutcome.CleanupUnavailable(string! moduleName, int remainingCount) -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +static CheatEngine.Client.Lua.LuaModuleReleaseOutcome.Create(string! moduleName, CheatEngine.Client.Results.LeaseReleaseKind kind, int removedCount, int restoredCount, int replacementCount, int remainingCount, System.Collections.Immutable.ImmutableArray failedExports) -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +static CheatEngine.Client.Lua.LuaModuleReleaseOutcome.PartiallyReleased(string! moduleName, int removedCount, int restoredCount, int replacementCount, System.Collections.Immutable.ImmutableArray failedExports) -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +static CheatEngine.Client.Lua.LuaModuleReleaseOutcome.RefusedRuntimeChanged(string! moduleName, int remainingCount) -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +static CheatEngine.Client.Lua.LuaModuleReleaseOutcome.Released(string! moduleName, int removedCount, int restoredCount, int replacementCount) -> CheatEngine.Client.Lua.LuaModuleReleaseOutcome! +static CheatEngine.Client.Lua.LuaScript.operator !=(CheatEngine.Client.Lua.LuaScript left, CheatEngine.Client.Lua.LuaScript right) -> bool +static CheatEngine.Client.Lua.LuaScript.operator ==(CheatEngine.Client.Lua.LuaScript left, CheatEngine.Client.Lua.LuaScript right) -> bool +static CheatEngine.Client.Memory.MemoryBytesReadRequest.operator !=(CheatEngine.Client.Memory.MemoryBytesReadRequest left, CheatEngine.Client.Memory.MemoryBytesReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryBytesReadRequest.operator ==(CheatEngine.Client.Memory.MemoryBytesReadRequest left, CheatEngine.Client.Memory.MemoryBytesReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryReadRequest.operator !=(CheatEngine.Client.Memory.MemoryReadRequest left, CheatEngine.Client.Memory.MemoryReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryReadRequest.operator ==(CheatEngine.Client.Memory.MemoryReadRequest left, CheatEngine.Client.Memory.MemoryReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringReadRequest.operator !=(CheatEngine.Client.Memory.MemoryStringReadRequest left, CheatEngine.Client.Memory.MemoryStringReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringReadRequest.operator ==(CheatEngine.Client.Memory.MemoryStringReadRequest left, CheatEngine.Client.Memory.MemoryStringReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryStringWriteRequest left, CheatEngine.Client.Memory.MemoryStringWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryStringWriteRequest left, CheatEngine.Client.Memory.MemoryStringWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryWriteRequest left, CheatEngine.Client.Memory.MemoryWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryWriteRequest left, CheatEngine.Client.Memory.MemoryWriteRequest right) -> bool +static CheatEngine.Client.Processes.LocalProcessEnumerationRequest.operator !=(CheatEngine.Client.Processes.LocalProcessEnumerationRequest left, CheatEngine.Client.Processes.LocalProcessEnumerationRequest right) -> bool +static CheatEngine.Client.Processes.LocalProcessEnumerationRequest.operator ==(CheatEngine.Client.Processes.LocalProcessEnumerationRequest left, CheatEngine.Client.Processes.LocalProcessEnumerationRequest right) -> bool +static CheatEngine.Client.Processes.LocalProcessId.operator !=(CheatEngine.Client.Processes.LocalProcessId left, CheatEngine.Client.Processes.LocalProcessId right) -> bool +static CheatEngine.Client.Processes.LocalProcessId.operator ==(CheatEngine.Client.Processes.LocalProcessId left, CheatEngine.Client.Processes.LocalProcessId right) -> bool +static CheatEngine.Client.Processes.LocalProcessSnapshot.operator !=(CheatEngine.Client.Processes.LocalProcessSnapshot left, CheatEngine.Client.Processes.LocalProcessSnapshot right) -> bool +static CheatEngine.Client.Processes.LocalProcessSnapshot.operator ==(CheatEngine.Client.Processes.LocalProcessSnapshot left, CheatEngine.Client.Processes.LocalProcessSnapshot right) -> bool +static CheatEngine.Client.Processes.ProcessSnapshot.operator !=(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool +static CheatEngine.Client.Processes.ProcessSnapshot.operator ==(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool +static CheatEngine.Client.Results.CheatEngineFailure.operator !=(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool +static CheatEngine.Client.Results.CheatEngineFailure.operator ==(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool +static CheatEngine.Client.Results.LeaseReleaseOutcome.operator !=(CheatEngine.Client.Results.LeaseReleaseOutcome left, CheatEngine.Client.Results.LeaseReleaseOutcome right) -> bool +static CheatEngine.Client.Results.LeaseReleaseOutcome.operator ==(CheatEngine.Client.Results.LeaseReleaseOutcome left, CheatEngine.Client.Results.LeaseReleaseOutcome right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilities.Create(System.ReadOnlySpan entries) -> CheatEngine.Client.Runtime.ClientCapabilities! +static CheatEngine.Client.Runtime.ClientCapabilities.Empty.get -> CheatEngine.Client.Runtime.ClientCapabilities! +static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator !=(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator ==(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool static CheatEngine.Client.Runtime.ClientCapabilityEvidence.operator !=(CheatEngine.Client.Runtime.ClientCapabilityEvidence left, CheatEngine.Client.Runtime.ClientCapabilityEvidence right) -> bool static CheatEngine.Client.Runtime.ClientCapabilityEvidence.operator ==(CheatEngine.Client.Runtime.ClientCapabilityEvidence left, CheatEngine.Client.Runtime.ClientCapabilityEvidence right) -> bool static CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.operator !=(CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate left, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate right) -> bool static CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.operator ==(CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate left, CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityId.Allocations.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Assembly.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.AutoAssemblerPatches.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Inspection.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.PatternScanning.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.ProcessSelection.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.ProtectedLua.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Tables.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.TypedMemory.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.UnsafeLuaExecution.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.ValueScanning.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.operator !=(CheatEngine.Client.Runtime.ClientCapabilityId left, CheatEngine.Client.Runtime.ClientCapabilityId right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityId.operator ==(CheatEngine.Client.Runtime.ClientCapabilityId left, CheatEngine.Client.Runtime.ClientCapabilityId right) -> bool +static CheatEngine.Client.Scanning.AobPattern.TryParse(string? value, out CheatEngine.Client.Scanning.AobPattern pattern) -> bool +static CheatEngine.Client.Scanning.AobPattern.operator !=(CheatEngine.Client.Scanning.AobPattern left, CheatEngine.Client.Scanning.AobPattern right) -> bool +static CheatEngine.Client.Scanning.AobPattern.operator ==(CheatEngine.Client.Scanning.AobPattern left, CheatEngine.Client.Scanning.AobPattern right) -> bool +static CheatEngine.Client.Scanning.AobScanRange.operator !=(CheatEngine.Client.Scanning.AobScanRange left, CheatEngine.Client.Scanning.AobScanRange right) -> bool +static CheatEngine.Client.Scanning.AobScanRange.operator ==(CheatEngine.Client.Scanning.AobScanRange left, CheatEngine.Client.Scanning.AobScanRange right) -> bool +static CheatEngine.Client.Scanning.AobScanRequest.operator !=(CheatEngine.Client.Scanning.AobScanRequest left, CheatEngine.Client.Scanning.AobScanRequest right) -> bool +static CheatEngine.Client.Scanning.AobScanRequest.operator ==(CheatEngine.Client.Scanning.AobScanRequest left, CheatEngine.Client.Scanning.AobScanRequest right) -> bool +static CheatEngine.Client.Scanning.PatternScanMetrics.operator !=(CheatEngine.Client.Scanning.PatternScanMetrics left, CheatEngine.Client.Scanning.PatternScanMetrics right) -> bool +static CheatEngine.Client.Scanning.PatternScanMetrics.operator ==(CheatEngine.Client.Scanning.PatternScanMetrics left, CheatEngine.Client.Scanning.PatternScanMetrics right) -> bool +static CheatEngine.Client.Scanning.ScanAlignment.AlignedTo(int divisor) -> CheatEngine.Client.Scanning.ScanAlignment +static CheatEngine.Client.Scanning.ScanAlignment.LastDigits(string! digits) -> CheatEngine.Client.Scanning.ScanAlignment +static CheatEngine.Client.Scanning.ScanAlignment.None.get -> CheatEngine.Client.Scanning.ScanAlignment +static CheatEngine.Client.Scanning.ScanAlignment.operator !=(CheatEngine.Client.Scanning.ScanAlignment left, CheatEngine.Client.Scanning.ScanAlignment right) -> bool +static CheatEngine.Client.Scanning.ScanAlignment.operator ==(CheatEngine.Client.Scanning.ScanAlignment left, CheatEngine.Client.Scanning.ScanAlignment right) -> bool +static CheatEngine.Client.Scanning.ScanProtectionFilter.operator !=(CheatEngine.Client.Scanning.ScanProtectionFilter left, CheatEngine.Client.Scanning.ScanProtectionFilter right) -> bool +static CheatEngine.Client.Scanning.ScanProtectionFilter.operator ==(CheatEngine.Client.Scanning.ScanProtectionFilter left, CheatEngine.Client.Scanning.ScanProtectionFilter right) -> bool +static CheatEngine.Client.Tables.MemoryRecordCollectionRequest.operator !=(CheatEngine.Client.Tables.MemoryRecordCollectionRequest left, CheatEngine.Client.Tables.MemoryRecordCollectionRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordCollectionRequest.operator ==(CheatEngine.Client.Tables.MemoryRecordCollectionRequest left, CheatEngine.Client.Tables.MemoryRecordCollectionRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordContentSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordContentSnapshot left, CheatEngine.Client.Tables.MemoryRecordContentSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordContentSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordContentSnapshot left, CheatEngine.Client.Tables.MemoryRecordContentSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordDefinition.operator !=(CheatEngine.Client.Tables.MemoryRecordDefinition left, CheatEngine.Client.Tables.MemoryRecordDefinition right) -> bool +static CheatEngine.Client.Tables.MemoryRecordDefinition.operator ==(CheatEngine.Client.Tables.MemoryRecordDefinition left, CheatEngine.Client.Tables.MemoryRecordDefinition right) -> bool +static CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.operator !=(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest left, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.operator ==(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest left, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSearch.operator !=(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSearch.operator ==(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordStateSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordStateSnapshot left, CheatEngine.Client.Tables.MemoryRecordStateSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordStateSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordStateSnapshot left, CheatEngine.Client.Tables.MemoryRecordStateSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordUpdate.operator !=(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool +static CheatEngine.Client.Tables.MemoryRecordUpdate.operator ==(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool +static CheatEngine.Client.Tables.TableLoadRequest.operator !=(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool +static CheatEngine.Client.Tables.TableLoadRequest.operator ==(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool +static CheatEngine.Client.Tables.TableSaveRequest.operator !=(CheatEngine.Client.Tables.TableSaveRequest left, CheatEngine.Client.Tables.TableSaveRequest right) -> bool +static CheatEngine.Client.Tables.TableSaveRequest.operator ==(CheatEngine.Client.Tables.TableSaveRequest left, CheatEngine.Client.Tables.TableSaveRequest right) -> bool +static CheatEngine.Client.Tables.TrustedTableFile.operator !=(CheatEngine.Client.Tables.TrustedTableFile left, CheatEngine.Client.Tables.TrustedTableFile right) -> bool +static CheatEngine.Client.Tables.TrustedTableFile.operator ==(CheatEngine.Client.Tables.TrustedTableFile left, CheatEngine.Client.Tables.TrustedTableFile right) -> bool +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanFirstRequest.Equals(object obj) -> bool +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanFirstRequest.ToString() -> string +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanMatch.Equals(object obj) -> bool +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanMatch.ToString() -> string +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanNextRequest.Equals(object obj) -> bool +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanNextRequest.ToString() -> string +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanReadRequest.Equals(object obj) -> bool +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanReadRequest.ToString() -> string +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanValue.Equals(object obj) -> bool +~[CECLIENT5001]override CheatEngine.Client.Scanning.ValueScanValue.ToString() -> string +~[CECLIENT5002]override CheatEngine.Client.Allocations.AllocationRequest.Equals(object obj) -> bool +~[CECLIENT5002]override CheatEngine.Client.Allocations.AllocationRequest.ToString() -> string +~[CECLIENT5003]override CheatEngine.Client.Assembly.AssemblyInstructionRequest.Equals(object obj) -> bool +~[CECLIENT5003]override CheatEngine.Client.Assembly.AssemblyInstructionRequest.ToString() -> string +~[CECLIENT5004]override CheatEngine.Client.Assembly.AutoAssemblerCheckResult.Equals(object obj) -> bool +~[CECLIENT5004]override CheatEngine.Client.Assembly.AutoAssemblerScript.Equals(object obj) -> bool +~[CECLIENT5004]override CheatEngine.Client.Assembly.AutoAssemblerScript.ToString() -> string +~override CheatEngine.Client.Inspection.InspectionCollectionRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Inspection.InspectionCollectionRequest.ToString() -> string +~override CheatEngine.Client.Inspection.SymbolRegistration.Equals(object obj) -> bool +~override CheatEngine.Client.Inspection.SymbolRegistration.ToString() -> string +~override CheatEngine.Client.Lua.LuaExportDescriptor.Equals(object obj) -> bool +~override CheatEngine.Client.Lua.LuaExportDescriptor.ToString() -> string +~override CheatEngine.Client.Lua.LuaScript.Equals(object obj) -> bool +~override CheatEngine.Client.Lua.LuaScript.ToString() -> string +~override CheatEngine.Client.Memory.MemoryBytesReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryBytesReadRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryReadRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryStringReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryStringReadRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryStringWriteRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryStringWriteRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryWriteRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryWriteRequest.ToString() -> string +~override CheatEngine.Client.Processes.LocalProcessEnumerationRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Processes.LocalProcessEnumerationRequest.ToString() -> string +~override CheatEngine.Client.Processes.LocalProcessId.Equals(object obj) -> bool +~override CheatEngine.Client.Processes.LocalProcessId.ToString() -> string +~override CheatEngine.Client.Processes.LocalProcessSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Processes.LocalProcessSnapshot.ToString() -> string +~override CheatEngine.Client.Processes.ProcessSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Processes.ProcessSnapshot.ToString() -> string +~override CheatEngine.Client.Results.CheatEngineFailure.Equals(object obj) -> bool +~override CheatEngine.Client.Results.CheatEngineFailure.ToString() -> string +~override CheatEngine.Client.Results.LeaseReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.ToString() -> string +~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ToString() -> string +~override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ToString() -> string +~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.ToString() -> string ~override CheatEngine.Client.Runtime.ClientCapabilityEvidence.Equals(object obj) -> bool ~override CheatEngine.Client.Runtime.ClientCapabilityEvidence.ToString() -> string ~override CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.Equals(object obj) -> bool ~override CheatEngine.Client.Runtime.ClientCapabilityEvidenceGate.ToString() -> string -CheatEngine.Client.Processes.IProcessClient.AttachForeground(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.Create(CheatEngine.Client.Processes.ProcessStartRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.GetPauseState(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessPauseSnapshot -CheatEngine.Client.Processes.IProcessClient.Pause(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.ResumeExecution(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot -CheatEngine.Client.Processes.IProcessClient.TryAttachForeground(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryCreate(CheatEngine.Client.Processes.ProcessStartRequest request, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryGetPauseState(out CheatEngine.Client.Processes.ProcessPauseSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryPause(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.IProcessClient.TryResumeExecution(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Processes.ProcessEnumerationRequest -CheatEngine.Client.Processes.ProcessEnumerationRequest.Equals(CheatEngine.Client.Processes.ProcessEnumerationRequest other) -> bool -CheatEngine.Client.Processes.ProcessEnumerationRequest.MaximumItems.get -> int -CheatEngine.Client.Processes.ProcessEnumerationRequest.NameContains.get -> string? -CheatEngine.Client.Processes.ProcessEnumerationRequest.ProcessEnumerationRequest() -> void -CheatEngine.Client.Processes.ProcessEnumerationRequest.ProcessEnumerationRequest(int maximumItems, string? nameContains = null) -> void -~override CheatEngine.Client.Processes.ProcessEnumerationRequest.Equals(object obj) -> bool -override CheatEngine.Client.Processes.ProcessEnumerationRequest.GetHashCode() -> int -~override CheatEngine.Client.Processes.ProcessEnumerationRequest.ToString() -> string -static CheatEngine.Client.Processes.ProcessEnumerationRequest.operator !=(CheatEngine.Client.Processes.ProcessEnumerationRequest left, CheatEngine.Client.Processes.ProcessEnumerationRequest right) -> bool -static CheatEngine.Client.Processes.ProcessEnumerationRequest.operator ==(CheatEngine.Client.Processes.ProcessEnumerationRequest left, CheatEngine.Client.Processes.ProcessEnumerationRequest right) -> bool -CheatEngine.Client.Processes.ProcessEnumerationResult -CheatEngine.Client.Processes.ProcessEnumerationResult.Equals(CheatEngine.Client.Processes.ProcessEnumerationResult other) -> bool -CheatEngine.Client.Processes.ProcessEnumerationResult.IsTruncated.get -> bool -CheatEngine.Client.Processes.ProcessEnumerationResult.ProcessEnumerationResult() -> void -CheatEngine.Client.Processes.ProcessEnumerationResult.ProcessEnumerationResult(System.Collections.Immutable.ImmutableArray processes, bool isTruncated) -> void -CheatEngine.Client.Processes.ProcessEnumerationResult.Processes.get -> System.Collections.Immutable.ImmutableArray -~override CheatEngine.Client.Processes.ProcessEnumerationResult.Equals(object obj) -> bool -override CheatEngine.Client.Processes.ProcessEnumerationResult.GetHashCode() -> int -~override CheatEngine.Client.Processes.ProcessEnumerationResult.ToString() -> string -static CheatEngine.Client.Processes.ProcessEnumerationResult.operator !=(CheatEngine.Client.Processes.ProcessEnumerationResult left, CheatEngine.Client.Processes.ProcessEnumerationResult right) -> bool -static CheatEngine.Client.Processes.ProcessEnumerationResult.operator ==(CheatEngine.Client.Processes.ProcessEnumerationResult left, CheatEngine.Client.Processes.ProcessEnumerationResult right) -> bool -CheatEngine.Client.Processes.ProcessInfoSnapshot -CheatEngine.Client.Processes.ProcessInfoSnapshot.Equals(CheatEngine.Client.Processes.ProcessInfoSnapshot other) -> bool -CheatEngine.Client.Processes.ProcessInfoSnapshot.ExecutablePath.get -> string? -CheatEngine.Client.Processes.ProcessInfoSnapshot.Id.get -> CheatEngine.Client.Processes.LocalProcessId -CheatEngine.Client.Processes.ProcessInfoSnapshot.Name.get -> string? -CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot() -> void -CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot(CheatEngine.Client.Processes.LocalProcessId id, string? name, string? executablePath) -> void -~override CheatEngine.Client.Processes.ProcessInfoSnapshot.Equals(object obj) -> bool -override CheatEngine.Client.Processes.ProcessInfoSnapshot.GetHashCode() -> int -~override CheatEngine.Client.Processes.ProcessInfoSnapshot.ToString() -> string -static CheatEngine.Client.Processes.ProcessInfoSnapshot.operator !=(CheatEngine.Client.Processes.ProcessInfoSnapshot left, CheatEngine.Client.Processes.ProcessInfoSnapshot right) -> bool -static CheatEngine.Client.Processes.ProcessInfoSnapshot.operator ==(CheatEngine.Client.Processes.ProcessInfoSnapshot left, CheatEngine.Client.Processes.ProcessInfoSnapshot right) -> bool -CheatEngine.Client.Processes.ProcessPauseSnapshot -CheatEngine.Client.Processes.ProcessPauseSnapshot.Equals(CheatEngine.Client.Processes.ProcessPauseSnapshot other) -> bool -CheatEngine.Client.Processes.ProcessPauseSnapshot.ProcessId.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId -CheatEngine.Client.Processes.ProcessPauseSnapshot.ProcessPauseSnapshot() -> void -CheatEngine.Client.Processes.ProcessPauseSnapshot.ProcessPauseSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, CheatEngine.Client.Processes.ProcessPauseState state, long selectionEpoch) -> void -CheatEngine.Client.Processes.ProcessPauseSnapshot.SelectionEpoch.get -> long -CheatEngine.Client.Processes.ProcessPauseSnapshot.State.get -> CheatEngine.Client.Processes.ProcessPauseState -~override CheatEngine.Client.Processes.ProcessPauseSnapshot.Equals(object obj) -> bool -override CheatEngine.Client.Processes.ProcessPauseSnapshot.GetHashCode() -> int -~override CheatEngine.Client.Processes.ProcessPauseSnapshot.ToString() -> string -static CheatEngine.Client.Processes.ProcessPauseSnapshot.operator !=(CheatEngine.Client.Processes.ProcessPauseSnapshot left, CheatEngine.Client.Processes.ProcessPauseSnapshot right) -> bool -static CheatEngine.Client.Processes.ProcessPauseSnapshot.operator ==(CheatEngine.Client.Processes.ProcessPauseSnapshot left, CheatEngine.Client.Processes.ProcessPauseSnapshot right) -> bool -CheatEngine.Client.Processes.ProcessPauseState -CheatEngine.Client.Processes.ProcessPauseState.Paused = 2 -> CheatEngine.Client.Processes.ProcessPauseState -CheatEngine.Client.Processes.ProcessPauseState.Running = 1 -> CheatEngine.Client.Processes.ProcessPauseState -CheatEngine.Client.Processes.ProcessPauseState.Unknown = 0 -> CheatEngine.Client.Processes.ProcessPauseState -CheatEngine.Client.Processes.ProcessStartRequest -CheatEngine.Client.Processes.ProcessStartRequest.Arguments.get -> string? -CheatEngine.Client.Processes.ProcessStartRequest.Equals(CheatEngine.Client.Processes.ProcessStartRequest other) -> bool -CheatEngine.Client.Processes.ProcessStartRequest.ExecutablePath.get -> string! -CheatEngine.Client.Processes.ProcessStartRequest.ProcessStartRequest() -> void -CheatEngine.Client.Processes.ProcessStartRequest.ProcessStartRequest(string! executablePath, string? arguments = null, string? workingDirectory = null) -> void -CheatEngine.Client.Processes.ProcessStartRequest.WorkingDirectory.get -> string? -~override CheatEngine.Client.Processes.ProcessStartRequest.Equals(object obj) -> bool -override CheatEngine.Client.Processes.ProcessStartRequest.GetHashCode() -> int -~override CheatEngine.Client.Processes.ProcessStartRequest.ToString() -> string -static CheatEngine.Client.Processes.ProcessStartRequest.operator !=(CheatEngine.Client.Processes.ProcessStartRequest left, CheatEngine.Client.Processes.ProcessStartRequest right) -> bool -static CheatEngine.Client.Processes.ProcessStartRequest.operator ==(CheatEngine.Client.Processes.ProcessStartRequest left, CheatEngine.Client.Processes.ProcessStartRequest right) -> bool -CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute -CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.BindingsType.get -> System.Type! -CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.CheatEngineLuaModuleAttribute(System.Type! bindingsType, string? name = null) -> void -CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.Name.get -> string? -CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute -CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.CheatEngineLuaOperationAttribute() -> void -CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.CheatEngineLuaOperationAttribute(System.Type! mapperType) -> void -CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.MapperType.get -> System.Type? -CheatEngine.Client.Lua.IDescribedLuaModule -CheatEngine.Client.Lua.IDescribedLuaModule.Descriptor.get -> CheatEngine.Client.Lua.LuaModuleDescriptor -CheatEngine.Client.Lua.ILuaClient.Execute(TOperation operation, System.Threading.CancellationToken cancellationToken) -> TResult -CheatEngine.Client.Lua.ILuaClient.TryExecute(TOperation operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken) -> bool -CheatEngine.Client.Lua.ILuaResultMapper -CheatEngine.Client.Lua.ILuaResultMapper.Map(TSource source) -> TResult -CheatEngine.Client.Lua.LuaExportDescriptor -CheatEngine.Client.Lua.LuaExportDescriptor.Equals(CheatEngine.Client.Lua.LuaExportDescriptor other) -> bool -CheatEngine.Client.Lua.LuaExportDescriptor.LuaExportDescriptor() -> void -CheatEngine.Client.Lua.LuaExportDescriptor.LuaExportDescriptor(string! name) -> void -CheatEngine.Client.Lua.LuaExportDescriptor.Name.get -> string! -CheatEngine.Client.Lua.LuaModuleDescriptor -CheatEngine.Client.Lua.LuaModuleDescriptor.Equals(CheatEngine.Client.Lua.LuaModuleDescriptor other) -> bool -CheatEngine.Client.Lua.LuaModuleDescriptor.Exports.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Lua.LuaModuleDescriptor.LuaModuleDescriptor() -> void -CheatEngine.Client.Lua.LuaModuleDescriptor.LuaModuleDescriptor(string! name, System.Collections.Immutable.ImmutableArray exports) -> void -CheatEngine.Client.Lua.LuaModuleDescriptor.Name.get -> string! -~override CheatEngine.Client.Lua.LuaExportDescriptor.Equals(object obj) -> bool -~override CheatEngine.Client.Lua.LuaExportDescriptor.ToString() -> string -~override CheatEngine.Client.Lua.LuaModuleDescriptor.Equals(object obj) -> bool -~override CheatEngine.Client.Lua.LuaModuleDescriptor.ToString() -> string -override CheatEngine.Client.Lua.LuaExportDescriptor.GetHashCode() -> int -override CheatEngine.Client.Lua.LuaModuleDescriptor.GetHashCode() -> int -static CheatEngine.Client.Lua.LuaExportDescriptor.operator !=(CheatEngine.Client.Lua.LuaExportDescriptor left, CheatEngine.Client.Lua.LuaExportDescriptor right) -> bool -static CheatEngine.Client.Lua.LuaExportDescriptor.operator ==(CheatEngine.Client.Lua.LuaExportDescriptor left, CheatEngine.Client.Lua.LuaExportDescriptor right) -> bool -static CheatEngine.Client.Lua.LuaModuleDescriptor.operator !=(CheatEngine.Client.Lua.LuaModuleDescriptor left, CheatEngine.Client.Lua.LuaModuleDescriptor right) -> bool -CheatEngine.Client.Allocations.IAllocationClient -CheatEngine.Client.Allocations.IAllocationClient.Allocate(CheatEngine.Client.Allocations.TargetAllocationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Allocations.ITargetMemoryLease! -CheatEngine.Client.Allocations.IAllocationClient.TryAllocate(CheatEngine.Client.Allocations.TargetAllocationRequest request, out CheatEngine.Client.Allocations.ITargetMemoryLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Allocations.ITargetMemoryLease -CheatEngine.Client.Allocations.ITargetMemoryLease.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Allocations.ITargetMemoryLease.IsReleased.get -> bool -CheatEngine.Client.Allocations.ITargetMemoryLease.SelectionEpoch.get -> long -CheatEngine.Client.Allocations.ITargetMemoryLease.Size.get -> long -CheatEngine.Client.Allocations.TargetAllocationAccess -CheatEngine.Client.Allocations.TargetAllocationAccess.ExecuteReadWrite = 1 -> CheatEngine.Client.Allocations.TargetAllocationAccess -CheatEngine.Client.Allocations.TargetAllocationAccess.ReadWrite = 0 -> CheatEngine.Client.Allocations.TargetAllocationAccess -CheatEngine.Client.Allocations.TargetAllocationRequest -CheatEngine.Client.Allocations.TargetAllocationRequest.Access.get -> CheatEngine.Client.Allocations.TargetAllocationAccess -CheatEngine.Client.Allocations.TargetAllocationRequest.Equals(CheatEngine.Client.Allocations.TargetAllocationRequest other) -> bool -CheatEngine.Client.Allocations.TargetAllocationRequest.Size.get -> long -CheatEngine.Client.Allocations.TargetAllocationRequest.TargetAllocationRequest() -> void -CheatEngine.Client.Allocations.TargetAllocationRequest.TargetAllocationRequest(long size, CheatEngine.Client.Allocations.TargetAllocationAccess access = CheatEngine.Client.Allocations.TargetAllocationAccess.ReadWrite) -> void -CheatEngine.Client.Assembly.AssemblyInstructionRequest -CheatEngine.Client.Assembly.AssemblyInstructionRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Assembly.AssemblyInstructionRequest.AssemblyInstructionRequest() -> void -CheatEngine.Client.Assembly.AssemblyInstructionRequest.AssemblyInstructionRequest(CheatEngine.SDK.Engine.Values.Address address, string! instruction) -> void -CheatEngine.Client.Assembly.AssemblyInstructionRequest.Equals(CheatEngine.Client.Assembly.AssemblyInstructionRequest other) -> bool -CheatEngine.Client.Assembly.AssemblyInstructionRequest.Instruction.get -> string! -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AssemblyInstructionSnapshot() -> void -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AssemblyInstructionSnapshot(CheatEngine.SDK.Engine.Values.Address address, int length, string! text, System.ReadOnlySpan bytes) -> void -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Bytes.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Equals(CheatEngine.Client.Assembly.AssemblyInstructionSnapshot other) -> bool -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Length.get -> int -CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Text.get -> string! -CheatEngine.Client.Assembly.AutoAssemblerScript -CheatEngine.Client.Assembly.AutoAssemblerScript.AutoAssemblerScript() -> void -CheatEngine.Client.Assembly.AutoAssemblerScript.AutoAssemblerScript(string! source, string? name = null) -> void -CheatEngine.Client.Assembly.AutoAssemblerScript.Equals(CheatEngine.Client.Assembly.AutoAssemblerScript other) -> bool -CheatEngine.Client.Assembly.AutoAssemblerScript.Name.get -> string? -CheatEngine.Client.Assembly.AutoAssemblerScript.Source.get -> string! -CheatEngine.Client.Assembly.IAssemblyClient -CheatEngine.Client.Assembly.IAssemblyClient.ApplyPatch(CheatEngine.Client.Assembly.AutoAssemblerScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.IAutoAssemblerPatchLease! -CheatEngine.Client.Assembly.IAssemblyClient.Assemble(CheatEngine.Client.Assembly.AssemblyInstructionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Assembly.IAssemblyClient.Disassemble(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.AssemblyInstructionSnapshot -CheatEngine.Client.Assembly.IAssemblyClient.GetComment(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! -CheatEngine.Client.Assembly.IAssemblyClient.GetInstructionSize(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> int -CheatEngine.Client.Assembly.IAssemblyClient.GetPreviousInstruction(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Assembly.IAssemblyClient.TryApplyPatch(CheatEngine.Client.Assembly.AutoAssemblerScript script, out CheatEngine.Client.Assembly.IAutoAssemblerPatchLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Assembly.IAssemblyClient.TryAssemble(CheatEngine.Client.Assembly.AssemblyInstructionRequest request, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Assembly.IAssemblyClient.TryDisassemble(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Assembly.AssemblyInstructionSnapshot instruction, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Assembly.IAssemblyClient.TryGetComment(CheatEngine.SDK.Engine.Values.Address address, out string? comment, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Assembly.IAssemblyClient.TryGetInstructionSize(CheatEngine.SDK.Engine.Values.Address address, out int size, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Assembly.IAssemblyClient.TryGetPreviousInstruction(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address previousAddress, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Assembly.IAutoAssemblerPatchLease -CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.IsReleased.get -> bool -CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.Name.get -> string? -CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.SelectionEpoch.get -> long -CheatEngine.Client.Dbvm.DbvmInitializationRequest -CheatEngine.Client.Dbvm.DbvmInitializationRequest.DbvmInitializationRequest() -> void -CheatEngine.Client.Dbvm.DbvmInitializationRequest.DbvmInitializationRequest(bool useStealthMode = false) -> void -CheatEngine.Client.Dbvm.DbvmInitializationRequest.Equals(CheatEngine.Client.Dbvm.DbvmInitializationRequest other) -> bool -CheatEngine.Client.Dbvm.DbvmInitializationRequest.UseStealthMode.get -> bool -CheatEngine.Client.Dbvm.DbvmState -CheatEngine.Client.Dbvm.DbvmState.Initialized = 3 -> CheatEngine.Client.Dbvm.DbvmState -CheatEngine.Client.Dbvm.DbvmState.NotInitialized = 2 -> CheatEngine.Client.Dbvm.DbvmState -CheatEngine.Client.Dbvm.DbvmState.Unavailable = 1 -> CheatEngine.Client.Dbvm.DbvmState -CheatEngine.Client.Dbvm.DbvmState.Unknown = 0 -> CheatEngine.Client.Dbvm.DbvmState -CheatEngine.Client.Dbvm.DbvmStatusSnapshot -CheatEngine.Client.Dbvm.DbvmStatusSnapshot.DbvmStatusSnapshot() -> void -CheatEngine.Client.Dbvm.DbvmStatusSnapshot.DbvmStatusSnapshot(CheatEngine.Client.Dbvm.DbvmState state, string? version = null) -> void -CheatEngine.Client.Dbvm.DbvmStatusSnapshot.Equals(CheatEngine.Client.Dbvm.DbvmStatusSnapshot other) -> bool -CheatEngine.Client.Dbvm.DbvmStatusSnapshot.State.get -> CheatEngine.Client.Dbvm.DbvmState -CheatEngine.Client.Dbvm.DbvmStatusSnapshot.Version.get -> string? -CheatEngine.Client.Dbvm.DbvmWatchEvent -CheatEngine.Client.Dbvm.DbvmWatchEvent.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Dbvm.DbvmWatchEvent.DbvmWatchEvent() -> void -CheatEngine.Client.Dbvm.DbvmWatchEvent.DbvmWatchEvent(CheatEngine.SDK.Engine.Values.Address address, int length, System.DateTimeOffset occurredAt) -> void -CheatEngine.Client.Dbvm.DbvmWatchEvent.Equals(CheatEngine.Client.Dbvm.DbvmWatchEvent other) -> bool -CheatEngine.Client.Dbvm.DbvmWatchEvent.Length.get -> int -CheatEngine.Client.Dbvm.DbvmWatchEvent.OccurredAt.get -> System.DateTimeOffset -CheatEngine.Client.Dbvm.DbvmWatchHandler -CheatEngine.Client.Dbvm.DbvmWatchRequest -CheatEngine.Client.Dbvm.DbvmWatchRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Dbvm.DbvmWatchRequest.DbvmWatchRequest() -> void -CheatEngine.Client.Dbvm.DbvmWatchRequest.DbvmWatchRequest(CheatEngine.SDK.Engine.Values.Address address, int length) -> void -CheatEngine.Client.Dbvm.DbvmWatchRequest.Equals(CheatEngine.Client.Dbvm.DbvmWatchRequest other) -> bool -CheatEngine.Client.Dbvm.DbvmWatchRequest.Length.get -> int -CheatEngine.Client.Dbvm.IDbvmClient -CheatEngine.Client.Dbvm.IDbvmClient.GetStatus(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Dbvm.DbvmStatusSnapshot -CheatEngine.Client.Dbvm.IDbvmClient.Initialize(CheatEngine.Client.Dbvm.DbvmInitializationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Dbvm.DbvmStatusSnapshot -CheatEngine.Client.Dbvm.IDbvmClient.RegisterWatch(CheatEngine.Client.Dbvm.DbvmWatchRequest request, CheatEngine.Client.Dbvm.DbvmWatchHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Dbvm.IDbvmWatchLease! -CheatEngine.Client.Dbvm.IDbvmClient.TryGetStatus(out CheatEngine.Client.Dbvm.DbvmStatusSnapshot status, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Dbvm.IDbvmClient.TryInitialize(CheatEngine.Client.Dbvm.DbvmInitializationRequest request, out CheatEngine.Client.Dbvm.DbvmStatusSnapshot status, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Dbvm.IDbvmClient.TryRegisterWatch(CheatEngine.Client.Dbvm.DbvmWatchRequest request, CheatEngine.Client.Dbvm.DbvmWatchHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Dbvm.IDbvmWatchLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Dbvm.IDbvmWatchLease -CheatEngine.Client.Dbvm.IDbvmWatchLease.Request.get -> CheatEngine.Client.Dbvm.DbvmWatchRequest -CheatEngine.Client.Dbvm.IDbvmWatchLease.SelectionEpoch.get -> long -CheatEngine.Client.Debugger.BreakpointDisposition -CheatEngine.Client.Debugger.BreakpointDisposition.Break = 1 -> CheatEngine.Client.Debugger.BreakpointDisposition -CheatEngine.Client.Debugger.BreakpointDisposition.Continue = 0 -> CheatEngine.Client.Debugger.BreakpointDisposition -CheatEngine.Client.Debugger.BreakpointEvent -CheatEngine.Client.Debugger.BreakpointEvent.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Debugger.BreakpointEvent.BreakpointEvent() -> void -CheatEngine.Client.Debugger.BreakpointEvent.BreakpointEvent(CheatEngine.SDK.Engine.Values.Address address, int threadId, System.Collections.Immutable.ImmutableArray registers) -> void -CheatEngine.Client.Debugger.BreakpointEvent.Equals(CheatEngine.Client.Debugger.BreakpointEvent other) -> bool -CheatEngine.Client.Debugger.BreakpointEvent.Registers.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Debugger.BreakpointEvent.ThreadId.get -> int -CheatEngine.Client.Debugger.BreakpointHandler -CheatEngine.Client.Debugger.BreakpointKind -CheatEngine.Client.Debugger.BreakpointKind.Access = 2 -> CheatEngine.Client.Debugger.BreakpointKind -CheatEngine.Client.Debugger.BreakpointKind.Execute = 0 -> CheatEngine.Client.Debugger.BreakpointKind -CheatEngine.Client.Debugger.BreakpointKind.Write = 1 -> CheatEngine.Client.Debugger.BreakpointKind -CheatEngine.Client.Debugger.BreakpointRegisterSnapshot -CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.BreakpointRegisterSnapshot() -> void -CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.BreakpointRegisterSnapshot(string! name, ulong value) -> void -CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Equals(CheatEngine.Client.Debugger.BreakpointRegisterSnapshot other) -> bool -CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Name.get -> string! -CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Value.get -> ulong -CheatEngine.Client.Debugger.BreakpointRequest -CheatEngine.Client.Debugger.BreakpointRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Debugger.BreakpointRequest.BreakpointRequest() -> void -CheatEngine.Client.Debugger.BreakpointRequest.BreakpointRequest(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.Client.Debugger.BreakpointKind kind = CheatEngine.Client.Debugger.BreakpointKind.Execute) -> void -CheatEngine.Client.Debugger.BreakpointRequest.Equals(CheatEngine.Client.Debugger.BreakpointRequest other) -> bool -CheatEngine.Client.Debugger.BreakpointRequest.Kind.get -> CheatEngine.Client.Debugger.BreakpointKind -CheatEngine.Client.Debugger.IBreakpointLease -CheatEngine.Client.Debugger.IBreakpointLease.Request.get -> CheatEngine.Client.Debugger.BreakpointRequest -CheatEngine.Client.Debugger.IBreakpointLease.SelectionEpoch.get -> long -CheatEngine.Client.Debugger.IDebuggerClient -CheatEngine.Client.Debugger.IDebuggerClient.RegisterBreakpoint(CheatEngine.Client.Debugger.BreakpointRequest request, CheatEngine.Client.Debugger.BreakpointHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Debugger.IBreakpointLease! -CheatEngine.Client.Debugger.IDebuggerClient.TryRegisterBreakpoint(CheatEngine.Client.Debugger.BreakpointRequest request, CheatEngine.Client.Debugger.BreakpointHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Debugger.IBreakpointLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Events.EventStreamOptions -CheatEngine.Client.Events.EventStreamOptions.Capacity.get -> int -CheatEngine.Client.Events.EventStreamOptions.Equals(CheatEngine.Client.Events.EventStreamOptions other) -> bool -CheatEngine.Client.Events.EventStreamOptions.EventStreamOptions() -> void -CheatEngine.Client.Events.EventStreamOptions.EventStreamOptions(int capacity, CheatEngine.Client.Events.EventStreamOverflowPolicy overflowPolicy = CheatEngine.Client.Events.EventStreamOverflowPolicy.DropOldest) -> void -CheatEngine.Client.Events.EventStreamOptions.OverflowPolicy.get -> CheatEngine.Client.Events.EventStreamOverflowPolicy -CheatEngine.Client.Events.EventStreamOverflowPolicy -CheatEngine.Client.Events.EventStreamOverflowPolicy.DropNewest = 1 -> CheatEngine.Client.Events.EventStreamOverflowPolicy -CheatEngine.Client.Events.EventStreamOverflowPolicy.DropOldest = 0 -> CheatEngine.Client.Events.EventStreamOverflowPolicy -CheatEngine.Client.Events.EventStreamOverflowPolicy.FailSubscription = 2 -> CheatEngine.Client.Events.EventStreamOverflowPolicy -CheatEngine.Client.Events.IEventStreamLease -CheatEngine.Client.Events.IEventStreamLease.DroppedEventCount.get -> long -CheatEngine.Client.Events.IEventStreamLease.Events.get -> System.Collections.Generic.IAsyncEnumerable! -CheatEngine.Client.Events.IEventStreamLease.IsReleased.get -> bool -CheatEngine.Client.Hashing.FileHashRequest -CheatEngine.Client.Hashing.FileHashRequest.Algorithm.get -> CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hashing.FileHashRequest.Equals(CheatEngine.Client.Hashing.FileHashRequest other) -> bool -CheatEngine.Client.Hashing.FileHashRequest.FileHashRequest() -> void -CheatEngine.Client.Hashing.FileHashRequest.FileHashRequest(string! filePath, CheatEngine.Client.Hashing.TargetHashAlgorithm algorithm = CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha256) -> void -CheatEngine.Client.Hashing.FileHashRequest.FilePath.get -> string! -CheatEngine.Client.Hashing.HashDigest -CheatEngine.Client.Hashing.HashDigest.Algorithm.get -> CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hashing.HashDigest.Equals(CheatEngine.Client.Hashing.HashDigest other) -> bool -CheatEngine.Client.Hashing.HashDigest.HashDigest() -> void -CheatEngine.Client.Hashing.HashDigest.HashDigest(CheatEngine.Client.Hashing.TargetHashAlgorithm algorithm, string! value) -> void -CheatEngine.Client.Hashing.HashDigest.Value.get -> string! -CheatEngine.Client.Hashing.IHashingClient -CheatEngine.Client.Hashing.IHashingClient.HashFile(CheatEngine.Client.Hashing.FileHashRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Hashing.HashDigest -CheatEngine.Client.Hashing.IHashingClient.HashMemory(CheatEngine.Client.Hashing.MemoryHashRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Hashing.HashDigest -CheatEngine.Client.Hashing.IHashingClient.TryHashFile(CheatEngine.Client.Hashing.FileHashRequest request, out CheatEngine.Client.Hashing.HashDigest digest, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Hashing.IHashingClient.TryHashMemory(CheatEngine.Client.Hashing.MemoryHashRequest request, out CheatEngine.Client.Hashing.HashDigest digest, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Hashing.MemoryHashRequest -CheatEngine.Client.Hashing.MemoryHashRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Hashing.MemoryHashRequest.Algorithm.get -> CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hashing.MemoryHashRequest.Equals(CheatEngine.Client.Hashing.MemoryHashRequest other) -> bool -CheatEngine.Client.Hashing.MemoryHashRequest.Length.get -> int -CheatEngine.Client.Hashing.MemoryHashRequest.MemoryHashRequest() -> void -CheatEngine.Client.Hashing.MemoryHashRequest.MemoryHashRequest(CheatEngine.SDK.Engine.Values.Address address, int length, CheatEngine.Client.Hashing.TargetHashAlgorithm algorithm = CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha256) -> void -CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hashing.TargetHashAlgorithm.Md5 = 0 -> CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha1 = 1 -> CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha256 = 2 -> CheatEngine.Client.Hashing.TargetHashAlgorithm -CheatEngine.Client.Hotkeys.HotkeyEvent -CheatEngine.Client.Hotkeys.HotkeyEvent.Equals(CheatEngine.Client.Hotkeys.HotkeyEvent other) -> bool -CheatEngine.Client.Hotkeys.HotkeyEvent.Gesture.get -> CheatEngine.Client.Hotkeys.HotkeyGesture -CheatEngine.Client.Hotkeys.HotkeyEvent.HotkeyEvent() -> void -CheatEngine.Client.Hotkeys.HotkeyEvent.HotkeyEvent(string! name, CheatEngine.Client.Hotkeys.HotkeyGesture gesture, System.DateTimeOffset occurredAt) -> void -CheatEngine.Client.Hotkeys.HotkeyEvent.Name.get -> string! -CheatEngine.Client.Hotkeys.HotkeyEvent.OccurredAt.get -> System.DateTimeOffset -CheatEngine.Client.Hotkeys.HotkeyGesture -CheatEngine.Client.Hotkeys.HotkeyGesture.Equals(CheatEngine.Client.Hotkeys.HotkeyGesture other) -> bool -CheatEngine.Client.Hotkeys.HotkeyGesture.HotkeyGesture() -> void -CheatEngine.Client.Hotkeys.HotkeyGesture.HotkeyGesture(int virtualKey, CheatEngine.Client.Hotkeys.HotkeyModifiers modifiers = CheatEngine.Client.Hotkeys.HotkeyModifiers.None) -> void -CheatEngine.Client.Hotkeys.HotkeyGesture.Modifiers.get -> CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyGesture.VirtualKey.get -> int -CheatEngine.Client.Hotkeys.HotkeyHandler -CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyModifiers.Alt = 1 -> CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyModifiers.Control = 2 -> CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyModifiers.None = 0 -> CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyModifiers.Shift = 4 -> CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyModifiers.Windows = 8 -> CheatEngine.Client.Hotkeys.HotkeyModifiers -CheatEngine.Client.Hotkeys.HotkeyRegistration -CheatEngine.Client.Hotkeys.HotkeyRegistration.Equals(CheatEngine.Client.Hotkeys.HotkeyRegistration other) -> bool -CheatEngine.Client.Hotkeys.HotkeyRegistration.Gesture.get -> CheatEngine.Client.Hotkeys.HotkeyGesture -CheatEngine.Client.Hotkeys.HotkeyRegistration.HotkeyRegistration() -> void -CheatEngine.Client.Hotkeys.HotkeyRegistration.HotkeyRegistration(string! name, CheatEngine.Client.Hotkeys.HotkeyGesture gesture) -> void -CheatEngine.Client.Hotkeys.HotkeyRegistration.Name.get -> string! -CheatEngine.Client.Hotkeys.IHotkeyClient -CheatEngine.Client.Hotkeys.IHotkeyClient.Register(CheatEngine.Client.Hotkeys.HotkeyRegistration registration, CheatEngine.Client.Hotkeys.HotkeyHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Hotkeys.IHotkeyLease! -CheatEngine.Client.Hotkeys.IHotkeyClient.TryRegister(CheatEngine.Client.Hotkeys.HotkeyRegistration registration, CheatEngine.Client.Hotkeys.HotkeyHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Hotkeys.IHotkeyLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Hotkeys.IHotkeyLease -CheatEngine.Client.Hotkeys.IHotkeyLease.Registration.get -> CheatEngine.Client.Hotkeys.HotkeyRegistration -CheatEngine.Client.RemoteExecution.IRemoteExecutionClient -CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.InjectLibrary(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.Invoke(CheatEngine.Client.RemoteExecution.RemoteCallRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.RemoteExecution.RemoteCallResult -CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.TryInjectLibrary(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.TryInvoke(CheatEngine.Client.RemoteExecution.RemoteCallRequest request, out CheatEngine.Client.RemoteExecution.RemoteCallResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.RemoteExecution.RemoteCallRequest -CheatEngine.Client.RemoteExecution.RemoteCallRequest.EntryPoint.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.RemoteExecution.RemoteCallRequest.Equals(CheatEngine.Client.RemoteExecution.RemoteCallRequest other) -> bool -CheatEngine.Client.RemoteExecution.RemoteCallRequest.Parameters.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.RemoteExecution.RemoteCallRequest.RemoteCallRequest() -> void -CheatEngine.Client.RemoteExecution.RemoteCallRequest.RemoteCallRequest(CheatEngine.SDK.Engine.Values.Address entryPoint, System.ReadOnlySpan parameters, System.TimeSpan timeout) -> void -CheatEngine.Client.RemoteExecution.RemoteCallRequest.Timeout.get -> System.TimeSpan -CheatEngine.Client.RemoteExecution.RemoteCallResult -CheatEngine.Client.RemoteExecution.RemoteCallResult.Equals(CheatEngine.Client.RemoteExecution.RemoteCallResult other) -> bool -CheatEngine.Client.RemoteExecution.RemoteCallResult.Output.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.RemoteExecution.RemoteCallResult.RemoteCallResult() -> void -CheatEngine.Client.RemoteExecution.RemoteCallResult.RemoteCallResult(ulong returnValue, System.ReadOnlySpan output) -> void -CheatEngine.Client.RemoteExecution.RemoteCallResult.ReturnValue.get -> ulong -CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest -CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.Equals(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest other) -> bool -CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.LibraryPath.get -> string! -CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.RemoteDllInjectionRequest() -> void -CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.RemoteDllInjectionRequest(string! libraryPath) -> void -CheatEngine.Client.Speed.ISpeedClient -CheatEngine.Client.Speed.ISpeedClient.GetMultiplier(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Speed.SpeedMultiplier -CheatEngine.Client.Speed.ISpeedClient.SetMultiplier(CheatEngine.Client.Speed.SpeedMultiplier multiplier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Speed.ISpeedClient.TryGetMultiplier(out CheatEngine.Client.Speed.SpeedMultiplier multiplier, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Speed.ISpeedClient.TrySetMultiplier(CheatEngine.Client.Speed.SpeedMultiplier multiplier, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Speed.SpeedMultiplier -CheatEngine.Client.Speed.SpeedMultiplier.Equals(CheatEngine.Client.Speed.SpeedMultiplier other) -> bool -CheatEngine.Client.Speed.SpeedMultiplier.SpeedMultiplier() -> void -CheatEngine.Client.Speed.SpeedMultiplier.SpeedMultiplier(double value) -> void -CheatEngine.Client.Speed.SpeedMultiplier.Value.get -> double -CheatEngine.Client.Timers.ITimerClient -CheatEngine.Client.Timers.ITimerClient.Register(CheatEngine.Client.Timers.TimerRequest request, CheatEngine.Client.Timers.TimerHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Timers.ITimerLease! -CheatEngine.Client.Timers.ITimerClient.TryRegister(CheatEngine.Client.Timers.TimerRequest request, CheatEngine.Client.Timers.TimerHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Timers.ITimerLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Timers.ITimerLease -CheatEngine.Client.Timers.ITimerLease.Request.get -> CheatEngine.Client.Timers.TimerRequest -CheatEngine.Client.Timers.TimerHandler -CheatEngine.Client.Timers.TimerRequest -CheatEngine.Client.Timers.TimerRequest.Equals(CheatEngine.Client.Timers.TimerRequest other) -> bool -CheatEngine.Client.Timers.TimerRequest.Interval.get -> System.TimeSpan -CheatEngine.Client.Timers.TimerRequest.TimerRequest() -> void -CheatEngine.Client.Timers.TimerRequest.TimerRequest(System.TimeSpan interval) -> void -CheatEngine.Client.Timers.TimerTick -CheatEngine.Client.Timers.TimerTick.Equals(CheatEngine.Client.Timers.TimerTick other) -> bool -CheatEngine.Client.Timers.TimerTick.OccurredAt.get -> System.DateTimeOffset -CheatEngine.Client.Timers.TimerTick.Sequence.get -> long -CheatEngine.Client.Timers.TimerTick.TimerTick() -> void -CheatEngine.Client.Timers.TimerTick.TimerTick(long sequence, System.DateTimeOffset occurredAt) -> void -~override CheatEngine.Client.Allocations.TargetAllocationRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Allocations.TargetAllocationRequest.ToString() -> string -~override CheatEngine.Client.Assembly.AssemblyInstructionRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Assembly.AssemblyInstructionRequest.ToString() -> string -~override CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.ToString() -> string -~override CheatEngine.Client.Assembly.AutoAssemblerScript.Equals(object obj) -> bool -~override CheatEngine.Client.Assembly.AutoAssemblerScript.ToString() -> string -~override CheatEngine.Client.Dbvm.DbvmInitializationRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Dbvm.DbvmInitializationRequest.ToString() -> string -~override CheatEngine.Client.Dbvm.DbvmStatusSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Dbvm.DbvmStatusSnapshot.ToString() -> string -~override CheatEngine.Client.Dbvm.DbvmWatchEvent.Equals(object obj) -> bool -~override CheatEngine.Client.Dbvm.DbvmWatchEvent.ToString() -> string -~override CheatEngine.Client.Dbvm.DbvmWatchRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Dbvm.DbvmWatchRequest.ToString() -> string -~override CheatEngine.Client.Debugger.BreakpointEvent.Equals(object obj) -> bool -~override CheatEngine.Client.Debugger.BreakpointEvent.ToString() -> string -~override CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Equals(object obj) -> bool -~override CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.ToString() -> string -~override CheatEngine.Client.Debugger.BreakpointRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Debugger.BreakpointRequest.ToString() -> string -~override CheatEngine.Client.Events.EventStreamOptions.Equals(object obj) -> bool -~override CheatEngine.Client.Events.EventStreamOptions.ToString() -> string -~override CheatEngine.Client.Hashing.FileHashRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Hashing.FileHashRequest.ToString() -> string -~override CheatEngine.Client.Hashing.HashDigest.Equals(object obj) -> bool -~override CheatEngine.Client.Hashing.HashDigest.ToString() -> string -~override CheatEngine.Client.Hashing.MemoryHashRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Hashing.MemoryHashRequest.ToString() -> string -~override CheatEngine.Client.Hotkeys.HotkeyEvent.Equals(object obj) -> bool -~override CheatEngine.Client.Hotkeys.HotkeyEvent.ToString() -> string -~override CheatEngine.Client.Hotkeys.HotkeyGesture.Equals(object obj) -> bool -~override CheatEngine.Client.Hotkeys.HotkeyGesture.ToString() -> string -~override CheatEngine.Client.Hotkeys.HotkeyRegistration.Equals(object obj) -> bool -~override CheatEngine.Client.Hotkeys.HotkeyRegistration.ToString() -> string -~override CheatEngine.Client.RemoteExecution.RemoteCallRequest.Equals(object obj) -> bool -~override CheatEngine.Client.RemoteExecution.RemoteCallRequest.ToString() -> string -~override CheatEngine.Client.RemoteExecution.RemoteCallResult.Equals(object obj) -> bool -~override CheatEngine.Client.RemoteExecution.RemoteCallResult.ToString() -> string -~override CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.Equals(object obj) -> bool -~override CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.ToString() -> string -~override CheatEngine.Client.Speed.SpeedMultiplier.Equals(object obj) -> bool -~override CheatEngine.Client.Speed.SpeedMultiplier.ToString() -> string -~override CheatEngine.Client.Timers.TimerRequest.Equals(object obj) -> bool -~override CheatEngine.Client.Timers.TimerRequest.ToString() -> string -~override CheatEngine.Client.Timers.TimerTick.Equals(object obj) -> bool -~override CheatEngine.Client.Timers.TimerTick.ToString() -> string -override CheatEngine.Client.Allocations.TargetAllocationRequest.GetHashCode() -> int -override CheatEngine.Client.Assembly.AssemblyInstructionRequest.GetHashCode() -> int -override CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.GetHashCode() -> int -override CheatEngine.Client.Assembly.AutoAssemblerScript.GetHashCode() -> int -override CheatEngine.Client.Dbvm.DbvmInitializationRequest.GetHashCode() -> int -override CheatEngine.Client.Dbvm.DbvmStatusSnapshot.GetHashCode() -> int -override CheatEngine.Client.Dbvm.DbvmWatchEvent.GetHashCode() -> int -override CheatEngine.Client.Dbvm.DbvmWatchRequest.GetHashCode() -> int -override CheatEngine.Client.Debugger.BreakpointEvent.GetHashCode() -> int -override CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.GetHashCode() -> int -override CheatEngine.Client.Debugger.BreakpointRequest.GetHashCode() -> int -override CheatEngine.Client.Events.EventStreamOptions.GetHashCode() -> int -override CheatEngine.Client.Hashing.FileHashRequest.GetHashCode() -> int -override CheatEngine.Client.Hashing.HashDigest.GetHashCode() -> int -override CheatEngine.Client.Hashing.MemoryHashRequest.GetHashCode() -> int -override CheatEngine.Client.Hotkeys.HotkeyEvent.GetHashCode() -> int -override CheatEngine.Client.Hotkeys.HotkeyGesture.GetHashCode() -> int -override CheatEngine.Client.Hotkeys.HotkeyRegistration.GetHashCode() -> int -override CheatEngine.Client.RemoteExecution.RemoteCallRequest.GetHashCode() -> int -override CheatEngine.Client.RemoteExecution.RemoteCallResult.GetHashCode() -> int -override CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.GetHashCode() -> int -override CheatEngine.Client.Speed.SpeedMultiplier.GetHashCode() -> int -override CheatEngine.Client.Timers.TimerRequest.GetHashCode() -> int -override CheatEngine.Client.Timers.TimerTick.GetHashCode() -> int -static CheatEngine.Client.Allocations.TargetAllocationRequest.operator !=(CheatEngine.Client.Allocations.TargetAllocationRequest left, CheatEngine.Client.Allocations.TargetAllocationRequest right) -> bool -static CheatEngine.Client.Allocations.TargetAllocationRequest.operator ==(CheatEngine.Client.Allocations.TargetAllocationRequest left, CheatEngine.Client.Allocations.TargetAllocationRequest right) -> bool -static CheatEngine.Client.Assembly.AssemblyInstructionRequest.operator !=(CheatEngine.Client.Assembly.AssemblyInstructionRequest left, CheatEngine.Client.Assembly.AssemblyInstructionRequest right) -> bool -static CheatEngine.Client.Assembly.AssemblyInstructionRequest.operator ==(CheatEngine.Client.Assembly.AssemblyInstructionRequest left, CheatEngine.Client.Assembly.AssemblyInstructionRequest right) -> bool -static CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.operator !=(CheatEngine.Client.Assembly.AssemblyInstructionSnapshot left, CheatEngine.Client.Assembly.AssemblyInstructionSnapshot right) -> bool -static CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.operator ==(CheatEngine.Client.Assembly.AssemblyInstructionSnapshot left, CheatEngine.Client.Assembly.AssemblyInstructionSnapshot right) -> bool -static CheatEngine.Client.Assembly.AutoAssemblerScript.operator !=(CheatEngine.Client.Assembly.AutoAssemblerScript left, CheatEngine.Client.Assembly.AutoAssemblerScript right) -> bool -static CheatEngine.Client.Assembly.AutoAssemblerScript.operator ==(CheatEngine.Client.Assembly.AutoAssemblerScript left, CheatEngine.Client.Assembly.AutoAssemblerScript right) -> bool -static CheatEngine.Client.Dbvm.DbvmInitializationRequest.operator !=(CheatEngine.Client.Dbvm.DbvmInitializationRequest left, CheatEngine.Client.Dbvm.DbvmInitializationRequest right) -> bool -static CheatEngine.Client.Dbvm.DbvmInitializationRequest.operator ==(CheatEngine.Client.Dbvm.DbvmInitializationRequest left, CheatEngine.Client.Dbvm.DbvmInitializationRequest right) -> bool -static CheatEngine.Client.Dbvm.DbvmStatusSnapshot.operator !=(CheatEngine.Client.Dbvm.DbvmStatusSnapshot left, CheatEngine.Client.Dbvm.DbvmStatusSnapshot right) -> bool -static CheatEngine.Client.Dbvm.DbvmStatusSnapshot.operator ==(CheatEngine.Client.Dbvm.DbvmStatusSnapshot left, CheatEngine.Client.Dbvm.DbvmStatusSnapshot right) -> bool -static CheatEngine.Client.Dbvm.DbvmWatchEvent.operator !=(CheatEngine.Client.Dbvm.DbvmWatchEvent left, CheatEngine.Client.Dbvm.DbvmWatchEvent right) -> bool -static CheatEngine.Client.Dbvm.DbvmWatchEvent.operator ==(CheatEngine.Client.Dbvm.DbvmWatchEvent left, CheatEngine.Client.Dbvm.DbvmWatchEvent right) -> bool -static CheatEngine.Client.Dbvm.DbvmWatchRequest.operator !=(CheatEngine.Client.Dbvm.DbvmWatchRequest left, CheatEngine.Client.Dbvm.DbvmWatchRequest right) -> bool -static CheatEngine.Client.Dbvm.DbvmWatchRequest.operator ==(CheatEngine.Client.Dbvm.DbvmWatchRequest left, CheatEngine.Client.Dbvm.DbvmWatchRequest right) -> bool -static CheatEngine.Client.Debugger.BreakpointEvent.operator !=(CheatEngine.Client.Debugger.BreakpointEvent left, CheatEngine.Client.Debugger.BreakpointEvent right) -> bool -static CheatEngine.Client.Debugger.BreakpointEvent.operator ==(CheatEngine.Client.Debugger.BreakpointEvent left, CheatEngine.Client.Debugger.BreakpointEvent right) -> bool -static CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.operator !=(CheatEngine.Client.Debugger.BreakpointRegisterSnapshot left, CheatEngine.Client.Debugger.BreakpointRegisterSnapshot right) -> bool -static CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.operator ==(CheatEngine.Client.Debugger.BreakpointRegisterSnapshot left, CheatEngine.Client.Debugger.BreakpointRegisterSnapshot right) -> bool -static CheatEngine.Client.Debugger.BreakpointRequest.operator !=(CheatEngine.Client.Debugger.BreakpointRequest left, CheatEngine.Client.Debugger.BreakpointRequest right) -> bool -static CheatEngine.Client.Debugger.BreakpointRequest.operator ==(CheatEngine.Client.Debugger.BreakpointRequest left, CheatEngine.Client.Debugger.BreakpointRequest right) -> bool -static CheatEngine.Client.Events.EventStreamOptions.operator !=(CheatEngine.Client.Events.EventStreamOptions left, CheatEngine.Client.Events.EventStreamOptions right) -> bool -static CheatEngine.Client.Events.EventStreamOptions.operator ==(CheatEngine.Client.Events.EventStreamOptions left, CheatEngine.Client.Events.EventStreamOptions right) -> bool -static CheatEngine.Client.Hashing.FileHashRequest.operator !=(CheatEngine.Client.Hashing.FileHashRequest left, CheatEngine.Client.Hashing.FileHashRequest right) -> bool -static CheatEngine.Client.Hashing.FileHashRequest.operator ==(CheatEngine.Client.Hashing.FileHashRequest left, CheatEngine.Client.Hashing.FileHashRequest right) -> bool -static CheatEngine.Client.Hashing.HashDigest.operator !=(CheatEngine.Client.Hashing.HashDigest left, CheatEngine.Client.Hashing.HashDigest right) -> bool -static CheatEngine.Client.Hashing.HashDigest.operator ==(CheatEngine.Client.Hashing.HashDigest left, CheatEngine.Client.Hashing.HashDigest right) -> bool -static CheatEngine.Client.Hashing.MemoryHashRequest.operator !=(CheatEngine.Client.Hashing.MemoryHashRequest left, CheatEngine.Client.Hashing.MemoryHashRequest right) -> bool -static CheatEngine.Client.Hashing.MemoryHashRequest.operator ==(CheatEngine.Client.Hashing.MemoryHashRequest left, CheatEngine.Client.Hashing.MemoryHashRequest right) -> bool -static CheatEngine.Client.Hotkeys.HotkeyEvent.operator !=(CheatEngine.Client.Hotkeys.HotkeyEvent left, CheatEngine.Client.Hotkeys.HotkeyEvent right) -> bool -static CheatEngine.Client.Hotkeys.HotkeyEvent.operator ==(CheatEngine.Client.Hotkeys.HotkeyEvent left, CheatEngine.Client.Hotkeys.HotkeyEvent right) -> bool -static CheatEngine.Client.Hotkeys.HotkeyGesture.operator !=(CheatEngine.Client.Hotkeys.HotkeyGesture left, CheatEngine.Client.Hotkeys.HotkeyGesture right) -> bool -static CheatEngine.Client.Hotkeys.HotkeyGesture.operator ==(CheatEngine.Client.Hotkeys.HotkeyGesture left, CheatEngine.Client.Hotkeys.HotkeyGesture right) -> bool -static CheatEngine.Client.Hotkeys.HotkeyRegistration.operator !=(CheatEngine.Client.Hotkeys.HotkeyRegistration left, CheatEngine.Client.Hotkeys.HotkeyRegistration right) -> bool -static CheatEngine.Client.Hotkeys.HotkeyRegistration.operator ==(CheatEngine.Client.Hotkeys.HotkeyRegistration left, CheatEngine.Client.Hotkeys.HotkeyRegistration right) -> bool -static CheatEngine.Client.RemoteExecution.RemoteCallRequest.operator !=(CheatEngine.Client.RemoteExecution.RemoteCallRequest left, CheatEngine.Client.RemoteExecution.RemoteCallRequest right) -> bool -static CheatEngine.Client.RemoteExecution.RemoteCallRequest.operator ==(CheatEngine.Client.RemoteExecution.RemoteCallRequest left, CheatEngine.Client.RemoteExecution.RemoteCallRequest right) -> bool -static CheatEngine.Client.RemoteExecution.RemoteCallResult.operator !=(CheatEngine.Client.RemoteExecution.RemoteCallResult left, CheatEngine.Client.RemoteExecution.RemoteCallResult right) -> bool -static CheatEngine.Client.RemoteExecution.RemoteCallResult.operator ==(CheatEngine.Client.RemoteExecution.RemoteCallResult left, CheatEngine.Client.RemoteExecution.RemoteCallResult right) -> bool -static CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.operator !=(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest left, CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest right) -> bool -static CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.operator ==(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest left, CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest right) -> bool -static CheatEngine.Client.Speed.SpeedMultiplier.operator !=(CheatEngine.Client.Speed.SpeedMultiplier left, CheatEngine.Client.Speed.SpeedMultiplier right) -> bool -static CheatEngine.Client.Speed.SpeedMultiplier.operator ==(CheatEngine.Client.Speed.SpeedMultiplier left, CheatEngine.Client.Speed.SpeedMultiplier right) -> bool -static CheatEngine.Client.Timers.TimerRequest.operator !=(CheatEngine.Client.Timers.TimerRequest left, CheatEngine.Client.Timers.TimerRequest right) -> bool -static CheatEngine.Client.Timers.TimerRequest.operator ==(CheatEngine.Client.Timers.TimerRequest left, CheatEngine.Client.Timers.TimerRequest right) -> bool -static CheatEngine.Client.Timers.TimerTick.operator !=(CheatEngine.Client.Timers.TimerTick left, CheatEngine.Client.Timers.TimerTick right) -> bool -static CheatEngine.Client.Timers.TimerTick.operator ==(CheatEngine.Client.Timers.TimerTick left, CheatEngine.Client.Timers.TimerTick right) -> bool -virtual CheatEngine.Client.Dbvm.DbvmWatchHandler.Invoke(CheatEngine.Client.Dbvm.DbvmWatchEvent watchEvent) -> void -virtual CheatEngine.Client.Debugger.BreakpointHandler.Invoke(CheatEngine.Client.Debugger.BreakpointEvent breakpointEvent) -> CheatEngine.Client.Debugger.BreakpointDisposition -virtual CheatEngine.Client.Hotkeys.HotkeyHandler.Invoke(CheatEngine.Client.Hotkeys.HotkeyEvent hotkeyEvent) -> void -virtual CheatEngine.Client.Timers.TimerHandler.Invoke(CheatEngine.Client.Timers.TimerTick tick) -> void -static CheatEngine.Client.Lua.LuaModuleDescriptor.operator ==(CheatEngine.Client.Lua.LuaModuleDescriptor left, CheatEngine.Client.Lua.LuaModuleDescriptor right) -> bool -CheatEngine.Client.Memory.IMemoryClient.ReadPrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Memory.IMemoryClient.TryReadPrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, out System.Collections.Immutable.ImmutableArray values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.TryWritePrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.IMemoryClient.WritePrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.MemoryAddressValue -CheatEngine.Client.Memory.MemoryAddressValue.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryAddressValue.MemoryAddressValue(CheatEngine.SDK.Engine.Values.Address address, T value) -> void -CheatEngine.Client.Memory.MemoryAddressValue.MemoryAddressValue() -> void -CheatEngine.Client.Memory.MemoryAddressValue.Value.get -> T -CheatEngine.Client.Memory.MemoryBatchLimits -const CheatEngine.Client.Memory.MemoryBatchLimits.MaximumOperations = 1024 -> int -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.Addresses.get -> System.Collections.Immutable.ImmutableArray -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.MemoryPrimitiveBatchReadRequest(System.ReadOnlySpan addresses) -> void -CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.MemoryPrimitiveBatchReadRequest() -> void -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.MemoryPrimitiveBatchWriteRequest(System.ReadOnlySpan> values) -> void -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.MemoryPrimitiveBatchWriteRequest() -> void -CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.Values.get -> System.Collections.Immutable.ImmutableArray> -CheatEngine.Client.Memory.MemoryStringEncoding -CheatEngine.Client.Memory.MemoryStringEncoding.Utf16 = 1 -> CheatEngine.Client.Memory.MemoryStringEncoding -CheatEngine.Client.Memory.MemoryStringEncoding.Utf8 = 0 -> CheatEngine.Client.Memory.MemoryStringEncoding -static CheatEngine.Client.Memory.MemoryStringReadRequest.Create(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding) -> CheatEngine.Client.Memory.MemoryStringReadRequest -CheatEngine.Client.Memory.MemoryStringReadRequest.Encoding.get -> CheatEngine.Client.Memory.MemoryStringEncoding -static CheatEngine.Client.Memory.MemoryStringWriteRequest.CreateBounded(CheatEngine.SDK.Engine.Values.Address address, string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding) -> CheatEngine.Client.Memory.MemoryStringWriteRequest -CheatEngine.Client.Memory.MemoryStringWriteRequest.Encoding.get -> CheatEngine.Client.Memory.MemoryStringEncoding -CheatEngine.Client.Memory.MemoryStringWriteRequest.MaximumLength.get -> int +~override CheatEngine.Client.Scanning.AobPattern.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanRange.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanRange.ToString() -> string +~override CheatEngine.Client.Scanning.AobScanRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanRequest.ToString() -> string +~override CheatEngine.Client.Scanning.PatternScanMetrics.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.PatternScanMetrics.ToString() -> string +~override CheatEngine.Client.Scanning.ScanAlignment.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.ScanAlignment.ToString() -> string +~override CheatEngine.Client.Scanning.ScanProtectionFilter.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.ScanProtectionFilter.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordDefinition.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordDefinition.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordSearch.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordSearch.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordSnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordUpdate.ToString() -> string +~override CheatEngine.Client.Tables.TableLoadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.TableLoadRequest.ToString() -> string +~override CheatEngine.Client.Tables.TableSaveRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.TableSaveRequest.ToString() -> string +~override CheatEngine.Client.Tables.TrustedTableFile.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.TrustedTableFile.ToString() -> string diff --git a/libs/CheatEngine.Client.Abstractions/README.md b/libs/CheatEngine.Client.Abstractions/README.md index 3e5ae28..fb3bb10 100644 --- a/libs/CheatEngine.Client.Abstractions/README.md +++ b/libs/CheatEngine.Client.Abstractions/README.md @@ -11,6 +11,23 @@ It defines the public contracts used by the product facade, implementations, flu hosting, extensions, and application code. It is intentionally synchronous: an attached Cheat Engine Lua runtime and its main-thread work must not be retained across an `await` boundary. +This README is the reference of every contract: its failures, its limits, the experimental APIs and the public API +charter. + +## Installation + +A plugin references [`CheatEngine.Client`](https://www.nuget.org/packages/CheatEngine.Client), which brings this +package at exactly its own version. The +[CheatEngine.Client README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/src/CheatEngine.Client/README.md) +gives the plugin project, the requirements (`net10.0`, C# 14, a .NET SDK 10.0.401 or later, Cheat Engine 7.7.0.10621 +x64, a direct `CheatEngine.SDK` reference in `[2.0.0, 3.0.0)`), a minimal plugin and the supported host profile. + +Reference `CheatEngine.Client.Abstractions` on its own only for code that depends on the contracts without an +implementation, such as a library of Client modules or a test double, and at the same version as every other Client +package: the seven packages ship in lockstep. This package depends on `CheatEngine.SDK` `[2.0.0, 3.0.0)` for its value +types only; the SDK's build, native and analyzer assets do not flow through it, so a plugin still references the SDK +directly. + ## Why This Project Exists The SDK correctly exposes the Cheat Engine runtime, Lua bridge, ownership wrappers, and ABI-level @@ -35,8 +52,9 @@ CheatEngine.Client.Abstractions ## How It Improves CheatEngine.Client - Makes domain behavior testable against interfaces instead of Cheat Engine statics. -- Keeps expected failures explicit through `Try...(..., out CheatEngineFailure)` and throws - `CheatEngineClientException`-derived exceptions only from the convenience methods. +- Keeps expected failures explicit through `Try...(..., out CheatEngineFailure)`; only the throwing + convenience methods throw, through `CheatEngineFailure.Throw(CancellationToken)`, and a cancellation + surfaces as an `OperationCanceledException`. - Keeps CE-owned objects out of the public surface: no `LuaState`, `LuaRef`, `CEObject`, `Owned`, or raw native handle escapes this package. - Requires bounded copies for scans, table snapshots, strings, byte reads, finite pointer chains, @@ -50,28 +68,28 @@ CheatEngine.Client.Abstractions Package and assembly names are not consumer namespaces. Public code belongs to functional namespaces only: -| Namespace | Responsibility | -|------------------------------|-----------------------------------------------------------------------| -| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade | -| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | -| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | -| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | -| `.Scanning` | AOB contracts and the value-scan session contract | -| `.Tables` | copied Address List records and explicitly trusted table I/O requests | -| `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | -| `.Allocations` / `.Assembly` | selection-bound allocation and reversible patch leases | -| `.RemoteExecution` | bounded DLL injection and remote-call requests | -| `.Debugger` / `.Hotkeys` | synchronous copied callbacks and bounded stream projections | -| `.Timers` / `.Speed` | activation-scoped timers and validated speed observations/mutations | -| `.Hashing` / `.Dbvm` | separate memory/file hashes and explicit DBVM observation/control | -| `.Results` | classified expected failures and lifecycle exceptions | +| Namespace | Responsibility | +|------------------------------|-----------------------------------------------------------------------------| +| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade, and `ICheatEngineLease` | +| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | +| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | +| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | +| `.Scanning` | AOB contracts and the value-scan session contract | +| `.Tables` | copied Address List records and explicitly trusted table I/O requests | +| `.Lua` | typed protected Lua operations, Lua modules and their leases | +| `.Modules` | client modules (`ICheatEngineClientModule`) that compose an activation | +| `.Allocations` / `.Assembly` | selection-bound allocation, instructions, and reversible patch leases | +| `.Results` | classified expected failures, exceptions, and lease release outcomes | No public consumer should use `CheatEngine.Client.Abstractions` as a namespace. +The "Public API charter" section below fixes the forms, the names and the vocabulary of every public type: which +interfaces are Call-only or Implementable, the enum rules, and the CheatEngine.SDK types a public signature may use. + ### Capability Boundary The contracts describe runtime, process, memory, inspection, AOB scanning, tables, protected Lua, -and explicitly disposable value-scan sessions. A contract is not an availability promise: callers +explicitly disposable value-scan sessions and target allocations. A contract is not an availability promise: callers must inspect `ICheatEngineRuntime` capability observations or handle `CapabilityUnavailable`. Each `ClientCapabilityAvailability` also exposes immutable `Evidence`: implementation, consumed package artifact, @@ -81,29 +99,928 @@ unavailable result. `Evidence.EffectiveReasonCode` is the stable, typed identity of the gate supplying `Evidence.EffectiveReason`; use it with that gate's public state instead of parsing the human-readable reason text or duplicating the Client's -deterministic -priority. The reason text remains available for display and diagnostics. +deterministic priority. The reason text remains available for display and diagnostics. + +The table below is what this Client build reports through `ICheatEngineRuntime.TryGetClientCapability`. No Client +capability is host-qualified yet: the qualification gate stays `Unknown` until a Client qualification receipt exists, +so no capability reports `Available`. The package gate of every capability is evidence, not a version name: it +compares the informational version of the loaded `CheatEngine.SDK.Engine` with the CheatEngine.SDK 2.0.0 package this +build consumed, and follows the range the packages declare. It is `Satisfied` for a release of the same major at or +above that version, by SemVer precedence (a prerelease of the consumed version is below it), and its reason says +whether the loaded assembly is exactly the reviewed package or another 2.x release; it is `Missing` for another major or +an older version, and `Unknown` when the loaded assembly declares no semantic informational version or the build embeds +no identity. Probes are read-only: taking a snapshot never loads a driver, runs remote code, changes the target or +allocates target memory. Each capability's qualification gate requires receipts for the live scenarios named in its +row. + + +| Capability id | Implementation | Package | Host | Qualification | Status reported at runtime | +|---|---|---|---|---|---| +| `Client.ProcessSelection` | Operational adapter | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | CheatEngine.SDK's read-only `Process.Current` observation | Unknown until Client receipts for Q30.a, Q31 and Q32 exist | `Unknown`; `Unavailable` when the package or host gate is `Missing` | +| `Client.TypedMemory` | Operational adapter | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q20, Q21 and Q33 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.PatternScanning` | Operational adapter | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q27, Q28 and Q29 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.ValueScanning` | Operational adapter, experimental (CECLIENT5001) | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q25 and Q26 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.Inspection` | Operational adapter | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q16.b and Q28 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.Tables` | Operational adapter | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q34 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.ProtectedLua` | Operational adapter | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q05, Q16 and Q19 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.UnsafeLuaExecution` | Operational, policy opt-in | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Stays `Unknown`: no scenario covers arbitrary Lua | `Unavailable` without `EnableUnsafeLuaExecution()`; otherwise `Unknown` | +| `Client.Allocations` | Operational adapter, experimental (CECLIENT5002) | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q30.a exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.Assembly` | Operational adapter, experimental (CECLIENT5003) | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q32 exist | `Unknown`; `Unavailable` when the package gate is `Missing` | +| `Client.AutoAssemblerPatches` | Operational, policy opt-in, experimental (CECLIENT5004) | Loaded CheatEngine.SDK 2.x at or above the consumed 2.0.0 | Not probed by the snapshot (`Unknown`) | Unknown until Client receipts for Q35 and Q44 exist | `Unavailable` without `EnableAutoAssemblerPatches()`; otherwise `Unknown` | + + +Every row also carries the lifetime gate (`Missing` once the activation has ended). -In particular, the value-scan contract and state model are published, but the Core implementation -does **not** currently create a live `MemScan`/`FoundList` session. The next SDK line now contains a -production owner factory with parent rollback and child-before-parent teardown, but Client -enablement remains blocked by the Cheat Engine 7.7 x64 ownership and reactivation live gate. Do -not treat `IValueScanner` as available until that gate promotes its capability. +`Client.ValueScanning` is an operational adapter over CheatEngine.SDK's scan sessions, published as an experimental +API (see "Experimental APIs" below): its implementation gate is `Satisfied`, and its qualification gate stays +`Unknown` until Client receipts for Q25 and Q26 exist. + +`Client.Allocations` is an operational adapter over CheatEngine.SDK's target allocator, also published as an +experimental API: its implementation gate is `Satisfied`, and its qualification gate stays `Unknown` until Client +receipts for Q30.a exist. `IUnsafeLuaClient` is intentionally separate from `ILuaClient` and is not registered by default. It is for explicitly trusted source only and still never exposes a raw Lua state. -## Contribution and Validation +`IAutoAssemblerClient` follows the same rule: it is not a property of `ICheatEngineClient`, and only +`CheatEngineClientBuilder.EnableAutoAssemblerPatches()` registers it and satisfies the policy gate of +`Client.AutoAssemblerPatches`. It is experimental (`CECLIENT5004`, see "Experimental APIs" below). + +`ICheatEngineClient.Assembly` (`IAssemblyClient`) is operational but experimental (`CECLIENT5003`, see "Experimental +APIs" below): it assembles, disassembles and measures single instructions and never writes target memory. + +### Experimental APIs + +An experimental API is marked `[Experimental("CECLIENT500x")]`: the compiler reports that diagnostic wherever the API is +used, and suppressing it (`$(NoWarn);CECLIENT5001` in the project, or a local +`#pragma warning disable CECLIENT5001`) is the explicit opt-in. An experimental API can change or be removed in a minor +release. Its id is lifted, and the API becomes stable, only when every live scenario of its capability passes on the +exact host profile of the release; the documentation link of each diagnostic points to its anchor below. + + + +#### CECLIENT5001: value scans + +- **Scope:** `ICheatEngineClient.ValueScans`, `IValueScanner`, `IValueScanSession` and their types: + `ValueScanFirstRequest`, `ValueScanNextRequest`, `ValueScanValue`, `ValueScanValueType`, `ValueScanComparison`, + `ValueScanReadRequest`, `ValueScanPage`, `ValueScanMatch`, `ValueScanSessionState` and `ValueScanInvalidationKind`. + The shared `ScanProtectionFilter` and `ScanAlignment` options are stable. +- **Behavior:** a session owns one Cheat Engine `MemScan` and its `FoundList`, created through CheatEngine.SDK's + scan-session factory for a target whose identity it could establish. A first or next scan starts Cheat Engine's scan + and waits for it in the same call, on Cheat Engine's main thread; a read copies one page of at most 1024 results, each + an address and Cheat Engine's value text. Read a typed value again with `IMemoryClient.ReadPrimitive(match.Address)`. + The session is a lease (`ICheatEngineLease`): its release destroys the found list, then the scanner, on the main thread, + and it is released before the plugin is disabled. Release it before selecting another process: once the Client + observes that Cheat Engine selected another process, it ends the session, CheatEngine.SDK refuses that release before + any Cheat Engine call (`RefusedTargetChanged` or `RefusedTargetIdentityUnavailable`, `RequiresManualRecovery`), and + the `MemScan` and its `FoundList` stay in Cheat Engine. +- **Known limits:** on Cheat Engine 7.7 the stop address is exclusive and the start address is not byte-exact. Cheat + Engine's wait runs queued main-thread work, and a call to the same session from that work is refused with + `InvalidState`. A scan cancelled after it started and before the Client waited for it stays `Scanning` until its + release asks Cheat Engine to stop it. `ValueScanValue.FromSingle` and `FromDouble` write the value in fixed-point + notation with the number of decimals the application passes (0 to 15) and a `.` separator, never in exponent notation: + Cheat Engine's rounded exact comparison takes its precision from those digits, and its Lua documentation states that + `3` matches 3.0 to 3.4999 while `3.0` matches 3.00 to 3.0499. An alignment divisor is written as decimal text, and an + ordered comparison follows Cheat Engine's own signedness rules; none of this has a Client receipt yet. +- **Exit criteria:** the Client receipts of Q25 (session lifecycle, results, release) and Q26 (target change and stale + owners) on the exact host profile; the capability's qualification gate stays `Unknown` until then. + + + +#### CECLIENT5002: target allocations + +- **Scope:** `ICheatEngineClient.Allocations`, `IAllocationClient`, `ITargetMemoryLease`, `AllocationRequest` and + `AllocationProtection`. +- **Behavior:** an allocation runs Cheat Engine's `allocateMemory` through CheatEngine.SDK's allocator, on Cheat + Engine's main thread, for a target whose identity it could establish, with the requested size, an explicit protection + (`PAGE_READWRITE` or `PAGE_EXECUTE_READWRITE`) and an optional preferred address. The allocation is a lease + (`ICheatEngineLease`): its release frees it with `deAlloc` on the main thread, only in the process incarnation and + the Lua runtime that made it. After Cheat Engine selected another process, or when the process identifier names + another process, the release is refused (`RefusedTargetChanged`) and nothing is freed in the new target: the Client + never selects the old process again. A refused or unconfirmed release sets `RequiresManualRecovery`, keeps + `Address` and `Size` readable, is never retried, and is reported when the plugin is disabled. A release that cannot + begin because CheatEngine.SDK detached is `CleanupUnavailable`: it frees nothing, is reported at deactivation too, + and does not set `RequiresManualRecovery`. The lease is released before the plugin is disabled. Once the Client + observes that Cheat Engine selected another process, it ends the lease with the refused release above, which leaves + the memory in the previous process: release allocations before selecting another process. When Cheat Engine + allocated but no lease could be published, the one compensating release is reported: `CleanupUnconfirmed`, with the + address in the failure message, when it was not confirmed. +- **Executable memory:** `AllocationProtection.ExecuteReadWrite` needs no opt-in beyond this diagnostic. The allocation + itself runs nothing; what the application writes into it, and executes, is its own responsibility. +- **Known limits:** Cheat Engine may round the size up to its page size and may allocate away from the preferred + address; the release passes the requested size back to `deAlloc`. CheatEngine.SDK cannot make its check of the + selected target atomic with the `deAlloc` that follows, so a selection change in that interval is not covered. None of + this has a Client receipt yet. +- **Exit criteria:** the Client receipt of Q30.a (allocation, release, and the refusal after a target change) on the + exact host profile; the capability's qualification gate stays `Unknown` until then. + + + +#### CECLIENT5003: instructions + +- **Scope:** `IAssemblyClient` (`TryAssemble`/`Assemble`, `TryDisassemble`/`Disassemble`, + `TryGetInstructionLength`/`GetInstructionLength`, `TryGetPreviousInstructionAddress`/`GetPreviousInstructionAddress`), + `AssemblyInstructionRequest`, `InstructionEncodingPreference`, `AssemblyInstructionSnapshot` and the + `ICheatEngineClient.Assembly` property. The capability id `Client.Assembly` is stable. +- **Profile:** each call observes Cheat Engine's selected target and its instruction profile (x86, x64, ARM32 or ARM64 + with its address width) once, through CheatEngine.SDK, in the same dispatched callback as its Cheat Engine calls. An + address above 4 GiB on a 32-bit profile is `OperationRejected` with `NotStarted`, before any instruction function of + Cheat Engine is called. CheatEngine.SDK checks the selected process again before and after every Cheat Engine call: + a target that changed meanwhile is `TargetChanged` and nothing is returned. The check is an observation, not a lock, + and the Client never selects a process or changes Cheat Engine's assembler mode. +- **Assemble:** the request carries the origin address, an `InstructionEncodingPreference` (`None`, `Short`, `Long`, + `Far`, passed to Cheat Engine's `assemble` unchanged) and `SkipRangeCheck`; with `SkipRangeCheck`, Cheat Engine emits + bytes even when a relative operand cannot reach its target. The bytes are valid only at that origin. A rejected + instruction is `OperationRejected` with `NotApplied`, and an empty result is `InvalidHostResult`. Nothing is written + to the target. +- **Disassemble:** `AddressText`, `Opcode` and `Extra` are Cheat Engine's disassembler columns, copied and never + parsed; `Text` is `Opcode`, followed by `Extra` when it is not blank. `Bytes` are read from target memory for the + `Length` Cheat Engine reports, never parsed from the disassembler's byte column, so `Bytes.Length` equals `Length`. + `GetPreviousInstructionAddress` returns Cheat Engine's estimate, which variable-length code cannot guarantee. +- **Bounds:** an assembly is copied into a 16-byte buffer, with one retry at the exact length CheatEngine.SDK reports; + assembled and disassembled bytes are bounded by `MemoryResourceLimits.MaximumReadBytes` and the disassembler's text by + `MaximumStringBytes`. A larger result is `ResultLimitExceeded`. A cancellation token is observed only before dispatch. +- **Outcomes:** an invalid or contradictory profile and a malformed result are `InvalidHostResult`; no selected target + is `TargetNotAttached`; a file opened as a process is `Unsupported`; an unavailable Cheat Engine function is + `CapabilityUnavailable` with `NotStarted`, or with `Completed` when an earlier instruction call of the same Client + call already returned (the retry of an assembly, the byte read and the disassembly that follow the length query); a + protected Lua failure is `LuaError`; a partial byte read is `MemoryReadFailed` and publishes no instruction. +- **Exit:** the attribute is removed once the live scenario Q32 (the x64 and the x86 instruction profiles) succeeds on + the exact host tuple the Client supports. + + + +#### CECLIENT5004: Auto Assembler patches + +- **Scope:** `IAutoAssemblerClient` (`TryCheck`/`Check`, `TryApplyPatch`/`ApplyPatch`), `AutoAssemblerScript`, + `AutoAssemblerCheckResult`, `IAutoAssemblerPatchLease` and `CheatEngineClientBuilder.EnableAutoAssemblerPatches()`. + The capability id `Client.AutoAssemblerPatches` is stable. +- **Opt-in:** nothing is registered without `EnableAutoAssemblerPatches()`; the capability's policy gate is then + `Missing`, and a client constructed without the opt-in refuses every call with `CapabilityUnavailable` and + `NotStarted`, before any Cheat Engine call. An Auto Assembler script can allocate target memory, inject code and run + Lua: apply only scripts your plugin owns. +- **Check:** `TryCheck` runs Cheat Engine's `autoAssembleCheck` on the `[ENABLE]` section. A rejection is a verdict + (`IsAccepted` is `false`, with Cheat Engine's bounded `HostMessages`), not a failure; an accepted section does not + prove that the activation will succeed. +- **Apply:** `TryApplyPatch` runs `autoAssemble` once through CheatEngine.SDK's `AutoAssemblerPatcher` (never with + `targetself`) and returns the lease that owns the disable information Cheat Engine returned. Releasing the lease + validates that the patch's target is still selected, then runs `[DISABLE]` once with that information. The Client + never rebuilds a `[DISABLE]` section, and it does not expose the disable information (allocations, registered + symbols) itself. The first release attempt that reaches CheatEngine.SDK consumes that information whatever its + result, so it ends the lease: a release refused on another target (`RefusedTargetChanged`), after a Lua runtime + detach or state reset or before the disable could begin (`RefusedRuntimeChanged`), or a disable Cheat Engine did not + confirm (`CleanupUnconfirmed`) leaves `RequiresManualRecovery` set and is never retried. +- **Target change:** release every patch lease before selecting another process. The lease is bound to the target + selection of the process CheatEngine.SDK applied the patch in, even when Cheat Engine's own window selected that + process since the Client last observed the selection; the leases of the process it replaced then end. The Client + observes a selection change only after Cheat Engine already targets the new process: it then ends the lease with + `RefusedTargetChanged`, CheatEngine.SDK consumes the disable information without running `[DISABLE]`, and the patch + stays in the previous process (`RequiresManualRecovery`). Selecting the previous process again cannot disable it. +- **Registration:** an activation that stopped or ended is refused before Cheat Engine applies anything. A lease that + cannot be registered after the activation, because the selection moved meanwhile, is released at once and reported + as `TargetChanged`, with `Completed` or, when that release was not confirmed, `CleanupUnconfirmed`; an activation that + stops or ends during the call throws its lifecycle exception, whose message says what that release left. +- **Outcomes:** `Applied` returns the lease; `AppliedTargetChanged` returns it with `AppliedAfterTargetChange` set and + logs warning event 1800 (the patch stays bound to the target observed before the activation); `Rejected` is + `OperationRejected` with an `Unknown` host effect (a rejected script can have applied part of its effects) and Cheat + Engine's bounded error text in `Message`; an unavailable `autoAssemble` is `CapabilityUnavailable` with + `NotStarted`; a protected Lua failure is `LuaError`; a malformed result is `InvalidHostResult`; an unqualified target + is `TargetIdentityUnavailable` with `NotStarted`; a failed ownership handoff is `BindingError` with + `CleanupUnconfirmed`. +- **Bounds:** Cheat Engine's host text (check messages, rejection detail, compilation warnings) is copied up to 4096 + UTF-8 bytes, never parsed, and is user data like `CheatEngineFailure.Message`. A cancellation token is observed only + before dispatch. +- **Exit:** the attribute is removed once the live scenarios Q35 (a benign patch applied then disabled, and a failing + variant) and Q44 (the policy refusal without the opt-in) succeed on the exact host tuple the Client supports. + +### Not offered in 1.0 + +These Cheat Engine features have no public Client contract, not even a gated placeholder: + +- timers and hotkeys; +- the debugger and breakpoints; +- the speed hack; +- target-memory and file hashing; +- DBVM; +- remote execution and DLL injection; +- pausing, resuming or creating a process, and attaching to the foreground process; +- assembly comments; +- detaching from a process. + +No CheatEngine.SDK primitive backs these yet; they may arrive in a 1.x minor release once the SDK provides an owner. + +CheatEngine.SDK 2.0.0 also resolves addresses in Cheat Engine's own process (`EngineInspection.ResolveHostAddress`) +and registers symbol lists (`SymbolLists`). Neither is a 1.0 goal of the Client: `IInspectionClient` resolves in the +target process only (`TryResolveAddress` with an `AddressResolutionMode`) and registers one symbol per lease. + +### AOB scan semantics and limits + +`IPatternScanner` picks one of three routes for each request. `PatternScanMetrics.Scope` names the one that ran and +`PatternScanOutcome.RouteReason` says why: + +| Route (`PatternScanScope`) | When (`PatternScanRouteReason`) | Answer | Cheat Engine work and cost | +|-----------------------------------|-------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `GlobalHostScan` | No module and no range (`UnscopedRequest`) | Exact matches; zero matches are `IndeterminateHostResult` | One global `AOBScan` over the whole target | +| `HostBoundedRange` | A module and/or range, on a qualified local target (`ScopedRequestOnQualifiedTarget`) | Exact matches; zero matches are a factual empty result when the error text was read | An exhaustive MemScan limited to the module intersected with the range; blocks Cheat Engine's main thread and cannot be interrupted once started in 1.0 | +| `GlobalHostScanWithManagedFilter` | A module and/or range whose bounded route cannot run (`TargetIdentityNotQualified`) | Exact matches inside the module and range; a list without such a match is an empty success; a `nil` list is `IndeterminateHostResult` | One global `AOBScan` over the whole target, after the bounded scan when that scan had run; Core applies the module and range while copying | + +The bounded route runs when `TargetSelection.ObserveCurrent` qualifies Cheat Engine's selected target as a local process +incarnation. A CEServer or file-as-process target, a MemScan session CheatEngine.SDK could not create, or a target the +SDK could not qualify during the scan falls back to the global route with managed filters. + +One scope rule applies on every route, so the same request returns the same addresses whichever route ran: + +- with a module, a match is kept only when all of its pattern bytes lie inside `[BaseAddress, BaseAddress + ImageSize)`; + a match that straddles the module end is never reported; +- with a range, a match is kept when its start lies in `[Start, End]`: the range end is the last allowed match start, + and the bounded route scans up to `End + pattern length`, saturated at the top of the address space. + +Core resolves the module before any scan, and a request whose module and range leave no room for one whole match (a +range that ends before the module can hold one, or a module smaller than the pattern) is refused (`OperationRejected`, +`NotStarted`) before any scan. `AobScanRequest.MaximumResults` bounds only how many addresses Core copies; it never stops +Cheat Engine early, and every route copies at most 65,535 addresses. The copied order is Cheat Engine's result-list +order, which Cheat Engine does not specify: the first copied address is not guaranteed to be the lowest address or the +first logical region. + +`AobScanResult.IsTruncated` means that the copy is not proven complete: more matches inside the request may exist, or +rows Cheat Engine returned were left unread. Every route sets it when it found one more match than it copied, cut by +either limit. The bounded route also sets it when its destination filled up with rows outside the request (for example +matches that straddle the module end) while rows stayed unread (`PatternScanMetrics.UnreadHostRowCount`): whether those +rows hold further matches is unknown, so the same request can report the same matches as complete on the global route, +which reads every row. `false` means that every match inside the request was copied. + +The memory protection and alignment of a scan are Client values: `ScanProtectionFilter` holds one +`ScanProtectionRequirement` (`Unspecified`, `Required`, `Excluded`, `Any`) per Cheat Engine flag (executable, +copy-on-write, writable), and `ScanAlignment` is `None`, `AlignedTo(divisor)` or `LastDigits(digits)`. Both validate +when they are created, and Core translates them into Cheat Engine's protection text (for example `+X-C-W`, or the +empty "find everything" text for the default filter) and fast-scan method on every route; no CheatEngine.SDK option type +appears in the public surface. A request that its constructor would refuse, like the `default` `AobScanRequest` or a +tampered option, throws an `ArgumentException` before the activation check and before any Cheat Engine call. + +Four scan limits are distinct and must not be confused: + +| Limit | Meaning | +|-------------------------|-------------------------------------------------------------------------------------------------------------------| +| Cheat Engine work limit | The bounds on `HostBoundedRange`; none on the global routes | +| Available results | `PatternScanMetrics.HostResultCount`, the number of rows Cheat Engine returned | +| Materialization limit | `AobScanRequest.MaximumResults`, which bounds `PatternScanMetrics.MaterializedCount` | +| Call deadline | None: cancellation is observed only between Cheat Engine calls and Client-managed steps | + +`IPatternScanner.ScanDetailed` returns a `PatternScanOutcome` with the same classification as `TryScan` (`IsSuccess`, +`Result`, `Failure`) plus: + +- `Metrics` (`PatternScanMetrics`): the scope, the host result count, the examined, filtered-out and copied counts, the + bounded route's below-start and at-or-after-stop skips (`BelowStartSkippedCount`, `AtOrAfterStopSkippedCount`), the + unread rows (`UnreadHostRowCount`), whether the in-request count is exact + (`InBoundsCountIsExact`), and the Cheat Engine scan time (`HostScanElapsed`) separately from the Client copy time + (`MaterializationElapsed`). Counts and durations never contain addresses and are safe to log. +- `HostOutcome` (`PatternScanHostOutcomeKind`): what Cheat Engine reported for the scan that ran, before the Client + decided the result, for example `NoResult` for a global `nil` or `HostReportedError` for a bounded error text. +- `RouteReason` (`PatternScanRouteReason`): why the scan ran on its route. +- `TargetIdentityVerified`: whether the copied addresses are attributed to one qualified local target incarnation for + the whole scan; always on a successful bounded scan, only when the selection was the same qualified incarnation + before and after the call on an unscoped global scan, and never on a failure or on the + `GlobalHostScanWithManagedFilter` route, whose `TargetIdentityNotQualified` reason it never contradicts. + +The global routes call `AobScanner.TryScanOutcome` of CheatEngine.SDK 2.0.0, which reports each host outcome +separately: + +| Host outcome | Client result | +|-------------------------------------|----------------------------------------------------------------------| +| A result list with matches | Success with the copied addresses | +| An empty result list | Success without addresses (a factual no-match) | +| `nil` (no result list) | `IndeterminateHostResult`, `Completed` | +| `AOBScan` absent or not callable | `CapabilityUnavailable`, `NotStarted` | +| A protected Lua error | `LuaError`, `Unknown`; the message names the Lua status | +| A value that is not a result list | `InvalidHostResult`, `Completed` | +| A list whose count cannot be read | `InvalidHostResult`, `Completed` | +| An outcome the Client does not know | `IndeterminateHostResult`, `Unknown` | + +On a global route a scan that finds nothing returns `IndeterminateHostResult` with the message "CE AOBScan returned +nil: on CE 7.7 zero matches and host failures share this shape": Cheat Engine 7.7 returns `nil` for zero matches, and a +host failure can return the same shape. It is never reported as `NotFound` or as a host rejection. The SDK also observes Cheat Engine's +selected target just before and just after the call: when the target changed in between, or its identity was lost or +gained, the addresses may belong to another process, so they are discarded and the scan fails with `TargetChanged` or +`TargetIdentityUnavailable` (`Completed`). The result list is released once through the SDK's `ReleaseWithOutcome`; any +outcome other than a confirmed release is `CleanupUnconfirmed`, and copied addresses are then discarded. + +The bounded route calls `AobScanner.TryScanWithinBounds`, the stable overload without a call deadline: + +| Host outcome | Client result | +|-------------------------------------------------------|---------------------------------------------------------------------------------------| +| In-bounds matches | Success with the copied addresses | +| No in-bounds match, error text read | Success without addresses: a factual zero | +| No in-bounds match, error text unreadable | `IndeterminateHostResult`, `Completed` | +| Cheat Engine reported an error text | `OperationRejected`, `Completed`; the message carries the bounded, unparsed text | +| Empty bounds | `OperationRejected`, `NotStarted` | +| Session not created, target not qualified in the scan | Fallback to the global route with managed filters | +| Target changed, Lua runtime changed | `TargetChanged`, `RuntimeChanged` | +| Protected Lua failure, malformed result | `LuaError`, `InvalidHostResult` | +| Cancellation observed by the SDK | `Cancelled`: `NotStarted` before the scan completed, `Completed` after it | +| Deadline expired, unknown outcome | `IndeterminateHostResult`, `Unknown` | + +The SDK releases the MemScan session once, child before parent, on every exit; any release that is not confirmed is +`CleanupUnconfirmed` and discards the copy, and a session whose creation rollback was not confirmed is never hidden +behind a fallback. Both routes report an unconfirmed release or rollback like the value-scan sessions: with the kind of +the failure that caused it, or `IndeterminateHostResult` when the scan itself succeeded, and `CleanupUnconfirmed`. + +### Target selection, runtime facts and pointer width + +Cheat Engine's selected target is ambient: `IProcessClient.Attach` changes Cheat Engine's global selection, and a +snapshot or a session that holds a process identifier does not stop the user, another plugin or a script from selecting +another process. `ProcessSnapshot.SelectionEpoch`, CheatEngine.SDK's PID-bracketed observation and, for a local process, +its incarnation (the PID and the creation time the SDK observed) reduce that risk for Client-owned leases; they are not +transactions. The selection epoch advances when the same PID denotes another process, but neither the SDK nor the Client +can see a selection that changed and changed back between two observations (A-B-A). `Attach` is CheatEngine.SDK's +`SelectAndObserve`: a normal return of Cheat Engine's selection call is not success until the selected process +identifier is read again. `ProcessSnapshot.Backend` says how Cheat Engine reaches the target. `ProcessSnapshot.StartTimeUtc` +(the creation time of the incarnation), `Name` and `ExecutablePath` describe a local process only: a CEServer target, a +file opened as a process or a target whose backend is not established never has them, and they do not prove liveness. +`IProcessClient.TryGetLocalProcesses` reads the local operating-system catalog offline: it never reaches Cheat Engine, +needs no current activation, and a local identifier is never evidence of a Cheat Engine target. + +Every fact is a read-only CheatEngine.SDK 2.0.0 observation that reads the selected process identifier before and after +the target facts, because Cheat Engine reports the same family, width and pointer size as an x64 target when no target +is opened. A fact the SDK could not establish stays unknown; none is inferred from another. `CheatEngineRuntimeSnapshot` +groups them in `Version`, `Platform` and `Capabilities` and keeps separate facts: + +- **Versions** (`CheatEngineRuntimeVersionInfo`): the complete four-part Cheat Engine file version + (`getCheatEngineFileVersion`), compared with the qualified baseline component by component as integers; the loaded + CheatEngine.SDK package version (`SdkPackageVersion`) and whether it is exactly the reviewed package + (`IsReviewedSdkPackage`). +- **Host** (`CheatEngineRuntimePlatformInfo`): the operating system (`HostOperatingSystem`), the host architecture + (`HostArchitecture`) and the bitness of Cheat Engine itself (`CheatEngineBitness`, a `PointerSize`, `Unknown` when not + observed), each from its own global. +- **Target backend** (`TargetBackend`): a local process, CEServer, a file opened as a process, or unknown. +- **Target architecture (ISA)**: CheatEngine.SDK's derivation from Cheat Engine's x86 and ARM family facts together with + its 64-bit fact, never from the 64-bit fact alone; contradictory or missing facts give + `CheatEngineArchitecture.Unknown`. +- **Bitness** (`CheatEngineRuntimePlatformInfo.TargetBitness`, `ProcessSnapshot.Bitness`): the target bitness + (`targetIs64Bit`, the process width `readPointer` follows) as observed; it can be known while the ISA is unknown. +- **Configured pointer size** (`ConfiguredPointerSizeBytes` and `ConfiguredPointerSize` on both types): the value Cheat + Engine reports through `getPointerSize()`. It is per-attachment state, independent of the bitness, reset when a process + is opened, and can hold any integer. `ConfiguredPointerSizeDiffersFromBitness` reports a mismatch as a fact, or `null` + when either value is unknown. + +No snapshot reports an external Lua state reset. Once CheatEngine.SDK detects that Cheat Engine replaced its Lua state +outside the plugin's control, it refuses every Lua admission, the snapshot's included: the snapshot then fails with +`RuntimeChanged` like all other Lua work, and Hosting logs the reset as a warning when it deactivates the plugin (event +8, see the Hosting README). + +Cheat Engine's pointer read follows the process width, not the configured size. The Client therefore passes the +observed process width to CheatEngine.SDK's width-qualified pointer reads and writes on every pointer-typed operation +(`Address` primitives, primitive batches, pointer chains). Before any memory access it refuses the operation with +`CheatEngineHostEffect.NotStarted` when the width is unknown (`InvalidState` for a selected target, otherwise the kind of +the status CheatEngine.SDK reported, such as `TargetNotAttached`) and when the configured size is known and differs +(`OperationRejected`). A configured size that could not be +observed is no evidence of a mismatch. On a 32-bit target nothing is truncated: writing an `Address` above 4 GiB is +refused with `OperationRejected` and `NotStarted`, a pointer value above 4 GiB returned by Cheat Engine is refused with +`OperationRejected` and `Completed`, and a pointer chain refuses a base or computed address above 4 GiB and names the hop +in its message. +Custom codecs receive the facts on `IMemoryReadContext` and `IMemoryWriteContext` (`Bitness`, `ConfiguredPointerSize`, +`ConfiguredPointerSizeBytes`, `ConfiguredPointerSizeDiffersFromBitness`); an unknown bitness is `PointerSize.Unknown`, and +the reason is reported if the codec then returns `false`. What the configured size affects besides the reported value is +not established. + +### Address List records and symbols + +A `MemoryRecordId` is valid in the Address List state in which this activation observed it. A trusted table load that +reached Cheat Engine (merge or replace, even a failed one) makes every identifier handed out before it stale, and every +identifier-taking operation refuses a stale identifier with `InvalidState` and `CheatEngineHostEffect.NotStarted` until a +new snapshot observes it again. The check runs before dispatch and again on Cheat Engine's main thread, where the load +advances the table generation, so concurrent callers cannot hand out or use an identifier of the earlier table state. +Loads made outside this activation are not detected. + +Every `ITableClient` read reports its failure the same way: an unavailable Address List is `CapabilityUnavailable` with +`NotStarted`, an absent record `NotFound`, a malformed one `InvalidHostResult`, and a copy above the caller's limit +(`GetSnapshot`, `Find`, `GetHierarchy`) `ResultLimitExceeded`. Each failure names the method the caller invoked. + +`ITableClient.TrySetActive` reports what Cheat Engine did: already in the requested state (success, the setter is not +called), applied (success), a pending asynchronous activation (success; the snapshot's `State.IsAsyncProcessing` is +`true` and a later snapshot observes the final state), refused by an activation callback, script or record type +(`OperationRejected`, `Started`, with the post-change snapshot) or indeterminate (`IndeterminateHostResult`, `Started`). The setter is called at most once and never retried. Delete, parent assignment +and activation are CheatEngine.SDK `AddressListMutations` commands. They are refused without changing the record, with +`NotStarted`, while a table file loads on Cheat Engine's main thread (`InvalidState`, a script of that table calling +the Client) or after Cheat Engine's Lua runtime changed (`RuntimeChanged`). Creation, update and selection, which +CheatEngine.SDK has no command for, are refused by the Client while one of its trusted table loads runs (`InvalidState`, +`NotStarted`). A parent assignment walks the chain above the requested parent up to 4096 records: a record as its own +parent or under one of its descendants is `OperationRejected`, a longer chain `ResultLimitExceeded`. A delete or parent +assignment that raised after it started +is `LuaError` with `Started` and is not retried. Selecting a record is a host-visible effect on Cheat Engine's user +interface. + +`IInspectionClient.TryRegisterSymbol` first resolves the name: a name that already resolves (a registered symbol, a +module or an expression that parses as an address) is refused with `OperationRejected` and `NotStarted`, and a failed +check registers nothing. The name is then registered through CheatEngine.SDK's symbol ownership coordinator; a +registration the SDK could not hand over to its lease was compensated once by the SDK and is reported with +`CleanupUnconfirmed`. `ISymbolRegistrationLease` is an `ICheatEngineLease`: `Release` unregisters the name only when it +still resolves to the leased address and no newer registration of the name through the SDK coordinator superseded the +lease, and reports `Released`, `Replaced` or `ExternallyRemoved` (the name no longer resolves to the address, left in +place), `Superseded`, `RefusedRuntimeChanged` (the Lua runtime of the registration is gone; the name may remain), +`CleanupUnconfirmed` (the unregistration began and failed) or the retryable `CleanupUnavailable` (the lease stays +active and the activation cleanup tries again). `Dispose` never throws. The check and the unregistration are not +atomic. + +### Failure, exception and cancellation contract -Changes here are public API changes. Keep request/value types immutable, preserve functional -namespaces, add XML documentation, update `PublicAPI.Unshipped.txt`, and add focused contract -tests in `tests/CheatEngine.Client.Abstractions.Tests` before moving an entry to -`PublicAPI.Shipped.txt`. +`Try*` does not mean "never throws". Every family follows three rules, then the per-family details below: -From the repository root, validate the complete package graph: +- **Returned as `CheatEngineFailure`:** refusals of well-formed requests (by a policy, a budget or the state they + meet, or because their values cannot be served together), pre-admission cancellation, Cheat Engine results that are + false, absent, indeterminate, or malformed, and every CheatEngine.SDK exception raised by Client-internal SDK work + (mapped by exception type and the SDK's own failure category, never by message text). No CheatEngine.SDK exception + is thrown by a `Try*` form; `CheatEngineFailure.Exception` may hold one, whose type is not part of the contract. A + Lua admission that the Client asks for itself (for example unsafe Lua or a generated Lua module) and that + CheatEngine.SDK refuses is `ActivationExpired`, `RuntimeChanged`, `InvalidState` (called off the main thread) or + `IndeterminateHostResult` (a status the Client does not recognize) with `NotStarted`, never `OperationRejected`. A + CheatEngine.SDK call that acquires its own admission (Address List mutations, table files, memory, inspection, + scans) raises a plain `InvalidOperationException` when it is refused: that is `OperationRejected` with `Unknown` + while the activation is current, `RuntimeChanged` after CheatEngine.SDK detected an external Lua state reset, and a + thrown `CheatEngineActivationExpiredException` once the activation ended. +- **Thrown:** an `ArgumentException` for a null argument, a `default` (uninitialized) request, an undefined enum value + or an out-of-range number (programming errors), from the `Try` form as from the throwing form, and first: before the + activation check and before any Cheat Engine call. A null argument throws `ArgumentNullException` and an undefined + enum value or an out-of-range number `ArgumentOutOfRangeException`, as the argument's own constructor or factory + does; a `default` request throws `ArgumentException` or one of these two, depending on the first field its check + meets. Then `CheatEngineActivationExpiredException` when the activation has ended and + `CheatEngineInvalidStateException` when it is stopping, outside the deactivation callbacks below: a `Try` form checks + the activation under its own operation name before it returns any failure, so a refusal of a well-formed request + never hides an ended or stopping activation (`IProcessClient.TryGetLocalProcesses` needs no activation), and an + expired activation is never reported as `Cancelled` or `CapabilityUnavailable`. An activation that ends while the + work is being dispatched to Cheat Engine's main thread is reported by the dispatcher, under the operation name + `Dispatcher.Invoke`. +- **Consumer code:** exceptions thrown by application-supplied code (dispatcher callbacks, `IMemoryCodec` codecs, + `ILuaOperation` operations, the `ILuaResultMapper` of a generated operation) are rethrown as the + same instance, never converted into a failure. A codec or an operation reports an expected failure by returning + `false` with its `out CheatEngineFailure failure`: a classified failure is published unchanged, including its host + effect, and the `default` failure lets the Client classify what it observed. -```powershell -dotnet restore CheatEngine.Client.slnx --locked-mode -dotnet build CheatEngine.Client.slnx --configuration Release --no-restore -dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore +**Deactivation callbacks.** When the plugin disables, `CheatEngineClientPlugin.OnClientDisabling` and each module's +`ICheatEngineClientModule.OnDisabling` run on Cheat Engine's main thread after `ICheatEngineClient.Stopping` was +cancelled and before the activation releases what it owns. A call they make on that thread still works on existing +state: `Memory`, `Patterns`, the reads of `Inspection`, the Address List records of `Tables`, `Runtime`, the current +process of `Processes`, `Dispatcher`, the operations of an existing value-scan session, and the release of any lease. +Every other call still throws `CheatEngineInvalidStateException` there: a call that creates a lease (a symbol +registration, a value-scan session, an allocation, an Auto Assembler patch, a Lua module), a process attach, Lua and +unsafe Lua execution, instructions, Auto Assembler scripts and table files. The context a memory codec receives refuses +too, so a codec read or write fails when the codec uses it; a current-process read fails when it finds a changed target +selection, which cannot advance while the activation stops; and a thread that a callback starts is refused like any +other caller. + +Every throwing convenience form (the method without `Try`, and the Fluent `Execute` terminals) returns the value of +its `Try` form or throws that form's failure through `CheatEngineFailure.Throw(cancellationToken)`, passing the token it +received. The exception type depends only on `CheatEngineFailure.Kind`, and every exception keeps the complete failure, +including its `HostEffect`: + +| `CheatEngineFailure.Kind` | Exception thrown | Base type | +|---|---|---| +| `Cancelled` | `CheatEngineOperationCanceledException`, whose `CancellationToken` is the token the operation observed | `OperationCanceledException` | +| `ActivationExpired` | `CheatEngineActivationExpiredException` | `CheatEngineClientException` | +| `InvalidState` | `CheatEngineInvalidStateException` | `CheatEngineClientException` | +| Any other kind, including a value this version does not define | `CheatEngineOperationException` | `CheatEngineClientException` | +| None: the `default` failure, which no operation returns | `InvalidOperationException` (a programming error) | `Exception` | + +No Client exception has a public constructor: `CheatEngineFailure.Throw(token)` throws one and +`CheatEngineFailure.ToException(token)` creates one, for code that needs an exception object without throwing it. + +A cancelled throwing call is therefore handled with `catch (OperationCanceledException)`, like any other .NET +cancellation; read `CheatEngineOperationCanceledException.Failure.HostEffect` to learn whether Cheat Engine work had +started. The `Try` form of the same call returns the same failure instead of throwing it: + +```csharp +using CheatEngine.Client; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Runtime; + +namespace MyPlugin; + +public sealed class TargetWidthModule : ICheatEngineClientModule +{ + public PointerSize TargetWidth { get; private set; } + + public void OnEnabled(ICheatEngineClient client) + { + // The Try form returns the expected failures of a well-formed request: classify them by Kind and HostEffect, + // never by Message. + if (client.Processes.TryGetCurrentProcess(out ProcessSnapshot process, out CheatEngineFailure failure)) + { + TargetWidth = process.Bitness; + } + else if (failure.Kind != CheatEngineFailureKind.TargetNotAttached) + { + // Throws exactly what the throwing form would have thrown. + failure.Throw(client.Stopping); + } + + // The throwing form returns the same value or throws the same failure. + try + { + TargetWidth = client.Processes.GetCurrentProcess(client.Stopping).Bitness; + } + catch (OperationCanceledException) + { + // The activation began stopping. + } + catch (CheatEngineClientException exception) + when (exception.Failure.HostEffect == CheatEngineHostEffect.NotStarted) + { + // Cheat Engine was not called: there is nothing to undo. + } + } + + public void OnDisabling(ICheatEngineClient client) + { + } +} ``` + +A `Try` method leaves its `failure` output `default` only when it returns `true`. The `default` failure is safe to +read: `IsDefault` is `true`, `Operation` and `Message` are empty strings (never `null`), `Kind` and `HostEffect` are +`Unknown`, and `Exception` is `null`. `CheatEngineFailure` has a single constructor, +`(kind, operation, message, exception = null, hostEffect = Unknown)`, which rejects an empty operation or message. + +`CheatEngineFailure.HostEffect` states how far the Cheat Engine primitive got: `NotStarted`, `Started` (effects may +persist), `Completed` (the primitive returned; the failure happened while Core copied or validated), `NotApplied` (the +primitive returned its documented negative result, so nothing was applied), `CleanupUnconfirmed` (a resource or change +may remain), or the conservative `Unknown`. A `CancellationToken` never interrupts a Cheat Engine +call that has started and never removes a callback, primitive, or effect that has begun: it is observed only before +dispatch and between Client-managed steps. + +| Family | Cancellation stops preventing the host effect at | `HostEffect` values produced | Partial effects | +|---|---|---|---| +| Dispatcher (`ICheatEngineDispatcher`) | Dispatch admission: a `Cancelled` result proves the callback did not run | `NotStarted` (cancelled), `Unknown` (infrastructure failure) | Whatever the callback did; callback exceptions are rethrown unchanged | +| Patterns / AOB (`IPatternScanner`, including `ScanDetailed`, Fluent `Aob`) | The start of the global `AOBScan` or of the bounded scan; the SDK also observes the token between the bounded route's Cheat Engine calls; later cancellation discards the copy | `NotStarted` (module lookup, a module and range without room for a whole match, cancellation before the scan, `AOBScan` unavailable), `Completed` (cancellation or invalid data after the scan, `IndeterminateHostResult` for a `nil` result, a target changed during the scan), `CleanupUnconfirmed` (result-list or session release, or session creation rollback, not confirmed: `IndeterminateHostResult` unless another failure caused it), `Unknown` (SDK fault during the scan call, protected Lua error, unrecognized outcome) | None published: a failed scan never returns a prefix | +| Memory primitives, codecs, bytes, strings, pointer chains (`IMemoryClient`) | Dispatch admission; one call is one Cheat Engine operation | `NotStarted` (budget, unsupported type, unknown or mismatched pointer width, unavailable memory global, a pointer value above a 32-bit target on a write, a pointer chain base address above it), `Completed` (a pointer value or computed chain address above a 32-bit target after the reads returned), `Unknown` (SDK fault, host refusal, a failed codec) | `ReadBytesDetailed` reports the confirmed prefix of a partial byte read; a codec may perform several reads or writes, and a failed write codec can leave earlier writes in place | +| Memory batches (`IMemoryClient.ReadPrimitiveBatchDetailed`, `WritePrimitiveBatchDetailed`) | Dispatch admission: a `Cancelled` dispatch reports `MemoryBatchWriteEffectState.NotStarted` | `NotStarted` (admission, pre-dispatch cancellation, unsupported type), `Started` (a completed prefix persists), `Unknown` (SDK fault or other dispatch failure) | `EffectState` is authoritative: `Partial` with `CompletedCount`/`FailedIndex`, never rolled back; `IsSuccess` is `true` only when every operation completed | +| Inspection and symbol leases (`IInspectionClient`) | Dispatch admission | `NotStarted` (an unavailable inspection global, name already reserved by this activation, name already resolves, failed collision check, Lua stack unavailable), `Started` (`registerSymbol` failed or returned an invalid result), `Completed` (the activation began stopping, or had drained its resources, before it owned the lease, and the registration was released), `CleanupUnconfirmed` (a registration CheatEngine.SDK could not hand over, or whose release was not confirmed), `Unknown` (SDK fault, unavailable `registerSymbol`) | A faulted or refused registration is not claimed and not retried by name; a replaced or superseded name is left in place; lease releases are reported as `LeaseReleaseOutcome`, never thrown | +| Tables (`ITableClient`) | Dispatch admission; `Find` filters a copied snapshot | `NotStarted` (policy, stale record identifier, an unavailable Address List, a mutation CheatEngine.SDK refused before changing the record: record or parent not found, self-parent, cycle, traversal limit, table load in progress, runtime changed; a creation, update or selection during a trusted table load; an unavailable table file function or Lua stack), `Started` (activation refused by the host or indeterminate; a delete or parent assignment that raised after it started; a table load or save that raised or returned an unexpected result), `Completed` (`Find` cancelled after the snapshot, failed `Create` whose rollback was confirmed, a completed mutation whose record could not be copied), `CleanupUnconfirmed` (record rollback not confirmed), `Unknown` (SDK fault, including a Lua admission CheatEngine.SDK refused inside an Address List command or a table file call, which is `OperationRejected` while the activation is current) | A failed `Create` deletes the partial record once and never retries; a refused activation can leave partial script effects; `loadTable` can execute table Lua | +| Lua typed operations and modules (`ILuaClient`) | Dispatch admission | `NotStarted` (cancellation, name already reserved by this activation, a Lua admission refused by CheatEngine.SDK), `NotApplied` (a global already defined, or a failed lookup or publication that the SDK rolled back completely), `CleanupUnconfirmed` (a publication whose rollback left a global, an earlier registration whose release may have left one, a success CheatEngine.SDK reported without a lease: `IndeterminateHostResult`), `Unknown` (SDK fault, another registration result the Client does not recognize: `IndeterminateHostResult`); otherwise the operation's own failure | A module release that fails is reported as `PartiallyReleased` with its failed globals and never retried; operation exceptions are rethrown unchanged | +| Unsafe Lua (`IUnsafeLuaClient`) | Dispatch admission | `NotStarted` (policy, or a Lua admission refused by CheatEngine.SDK), `Unknown` (SDK fault; the script may have run partially) | The script may have run partially before a Lua error | +| Runtime and Processes (`ICheatEngineRuntime`, `IProcessClient`) | Dispatch admission; `AttachExactName` also observes it before the local process catalog, and `GetLocalProcesses`, which never dispatches, between catalog steps (`NotStarted`) | `Completed` (CheatEngine.SDK reported a status that establishes no target: `TargetChanged`, `TargetIdentityUnavailable` for a file opened as a process, `CapabilityUnavailable`, `LuaError`, `InvalidHostResult`), `Unknown` (SDK fault, no selected target, an attach that CheatEngine.SDK refused or could not confirm) | A fact CheatEngine.SDK could not read stays `Unknown` in the snapshot instead of failing the call; `Attach` changes Cheat Engine's global selection | +| Auto Assembler patches (`IAutoAssemblerClient`, experimental `CECLIENT5004`) | Dispatch admission: a check or an activation that began is never interrupted, and an applied patch is always returned as a lease | `NotStarted` (policy without `EnableAutoAssemblerPatches()`, cancellation, Lua admission, unavailable global, unqualified target), `Unknown` (rejection, Lua error, malformed result, SDK fault, an outcome the Client does not recognize: `IndeterminateHostResult`), `CleanupUnconfirmed` (failed ownership handoff, an applied script without an owner: `IndeterminateHostResult`, an owner released incompletely next to a failed activation, or a lease that could not be registered and whose release was not confirmed), `Completed` (a lease that could not be registered and was released) | A rejected script can have applied part of its effects; a release refused on another target leaves the patch in place (`RequiresManualRecovery`), so release every lease before selecting another process | +| Instructions (`IAssemblyClient`, experimental `CECLIENT5003`) | Dispatch admission: the profile observation, the operation, its one retry and the byte read run in one dispatched callback | `NotStarted` (cancellation, Lua admission, failed profile observation, address wider than the profile, unavailable global before the first instruction call), `NotApplied` (rejected instruction), `Completed` (result above the Client bound, estimate wider than the profile, malformed length, text or empty assembly, a step refused after an earlier instruction call returned), `Unknown` (changed target, Lua error, malformed result, failed byte read, SDK fault) | None: no operation writes target memory, and a failed call publishes no bytes and no prefix | +| Value scans (`IValueScanner`, `IValueScanSession`) | The start of Cheat Engine's first or next scan; a cancellation between the start and the wait leaves the session `Scanning`, and a later one discards the result | `NotStarted` (a next-scan value of another type than the first scan, session state, re-entrant call, changed target or runtime, cancellation before the start, a page beyond Cheat Engine's 32-bit result index), `Started` (a scan, wait or reset call that failed or was cancelled before the wait), `Completed` (cancellation after the wait or the copy, a malformed count or page), `NotApplied` (a refused creation that CheatEngine.SDK rolled back), `CleanupUnconfirmed` (creation rollback not confirmed, a failed creation whose handle was released incompletely, or a creation status the Client does not recognize), `Unknown` (SDK fault) | A read publishes a whole page or nothing; a failed scan leaves the session `Invalidated` until a reset | +| Allocations (`IAllocationClient`, `ITargetMemoryLease`) | The `allocateMemory` call; a later cancellation frees the new allocation and publishes no lease | `NotStarted` (cancellation before the call, target identity unavailable or changed, unavailable global), `NotApplied` (`allocateMemory` returned nil), `Completed` (cancellation after the call, or an allocation without owner whose compensating release was confirmed), `CleanupUnconfirmed` (that release was refused or not confirmed; the message carries the address), `Unknown` (SDK fault, Lua error, malformed result) | An allocation is published as a lease or released at once; a refused or unconfirmed release is never retried and requires manual recovery | + +### Failure kinds and host effects + +`CheatEngineFailureKind` says why an operation failed and `CheatEngineHostEffect` says how far the Cheat Engine primitive +got. Both are `int` enums whose values never change meaning; new values can be added, so handle an unrecognized value +like `Unknown`. + +| `CheatEngineFailureKind` | Value | Meaning | +|---|---|---| +| `Unknown` | 0 | The failure could not be classified more precisely | +| `Cancelled` | 1 | The caller's token was observed; `HostEffect` tells whether Cheat Engine work had started | +| `CapabilityUnavailable` | 2 | A required Cheat Engine capability or Lua global is unavailable, or the activation did not enable it | +| `OperationRejected` | 3 | Cheat Engine or the Client rejected the request | +| `NotFound` | 4 | Absence of the requested resource was established | +| `AmbiguousMatch` | 5 | One result was expected and several were observed | +| `ResultLimitExceeded` | 6 | The host result exceeded the caller's materialization limit | +| `LuaError` | 7 | A protected Lua call failed | +| `BindingError` | 8 | A CheatEngine.SDK binding could not uphold its documented contract | +| `InvalidHostResult` | 9 | Cheat Engine returned a value outside the documented result shape | +| `Unsupported` | 10 | The feature is intentionally not supported by this Client version | +| `TargetNotAttached` | 11 | No target process is attached | +| `MemoryReadFailed` | 12 | A target-memory read failed | +| `MemoryWriteFailed` | 13 | A target-memory write failed | +| `ActivationExpired` | 14 | The Client activation that owns the call or resource has ended | +| `InvalidState` | 15 | The operation is not valid in the current lifecycle or session state | +| `IndeterminateHostResult` | 16 | Several documented causes (for example no match and a host failure) are indistinguishable; never treat it as absence | +| `TargetChanged` | 17 | The target the call or resource was bound to is no longer Cheat Engine's selected target: another process, or another incarnation of the same process identifier | +| `TargetIdentityUnavailable` | 18 | The identity of Cheat Engine's current target could not be established, so the call was refused instead of running against an unverified target | +| `RuntimeChanged` | 19 | Cheat Engine's Lua runtime was replaced outside the plugin's control, or the resource belongs to an earlier Lua attachment; disable and re-enable the plugin to recover | + +When CheatEngine.SDK reports the effect state of an effectful operation, Core maps it value by value; the other host +effects are observed by the Client itself. + +| `CheatEngineHostEffect` | Value | Meaning | CheatEngine.SDK `EngineEffectState` mapped to it | +|---|---|---|---| +| `Unknown` | 0 | Any effect is possible | `Unknown`, and any value this Client version does not know | +| `NotStarted` | 1 | The primitive was not invoked | `NotStarted` | +| `Started` | 2 | The primitive was invoked; neither its completion nor a rollback was established | None: observed by the Client | +| `Completed` | 3 | The primitive ran to completion; the failure happened afterwards inside the Client | `Applied` | +| `CleanupUnconfirmed` | 4 | A resource or change may remain because its release or rollback was not confirmed | None: observed by the Client | +| `NotApplied` | 5 | The primitive returned its documented negative result: nothing was applied and nothing needs cleanup | `NotApplied` | + +Every target-memory failure CheatEngine.SDK reports (`MemoryAccessFailure`) maps value by value; the message names the +category, never an address or a value. + +| CheatEngine.SDK `MemoryAccessFailure` | `CheatEngineFailureKind` | `CheatEngineHostEffect` | +|---|---|---| +| `GlobalUnavailable` | `CapabilityUnavailable` | `NotStarted` | +| `LuaError` | `LuaError` | `Unknown` | +| `ReadFailed` | `MemoryReadFailed` | `Unknown` | +| `PartialRead` | `MemoryReadFailed`; `ReadBytesDetailed` keeps the confirmed prefix | `Unknown` | +| `DestinationTooSmall` | `ResultLimitExceeded` | `Unknown` | +| `PointerWidthUnknown` | `InvalidState` | `NotStarted` | +| `PointerValueExceedsTargetWidth` | `OperationRejected`; a pointer chain names the hop | `NotStarted` for a write, `Completed` for a read | +| `WriteFailed` | `MemoryWriteFailed` | `Unknown` | +| `InvalidResult` | `InvalidHostResult` | `Unknown` | +| A failure without a recognized cause | `IndeterminateHostResult` | `Unknown` | + +`IMemoryClient.ReadBytesDetailed` reads through CheatEngine.SDK's counted byte read and returns a +`MemoryBytesReadOutcome`: `Bytes` is the contiguous prefix CheatEngine.SDK verified (`ConfirmedLength` of +`RequestedLength`), `IsSuccess` says whether every byte arrived, and `Failure` says why not. A partial copy +is therefore never confused with a host failure that copied nothing. `TryReadBytes` and `ReadBytes` report the same +failure and publish all or nothing; a codec context read that does not fill its buffer returns `false` and leaves the +buffer cleared. + +### Leases and release outcomes + +Every Client lease implements `ICheatEngineLease` (`IDisposable`): `Release()` releases the resource on Cheat Engine's +main thread and returns a `LeaseReleaseOutcome`; `Dispose()` performs the same release, **never throws**, and discards +the outcome; `LastReleaseOutcome` keeps the outcome of the attempt that ended the lease, `RequiresManualRecovery` says +that what the lease owns may remain and no later release of this lease can remove it, and `IsReleased` says that no +later attempt will be made. A repeated release of an ended lease returns its `LastReleaseOutcome` unchanged, without a +Cheat Engine call, so a refused or unconfirmed release never reads as complete the second time. The outcome's `Kind` +says what happened and its `HostEffect` how far the release call got; exactly one of three flags is `true`: + +| `LeaseReleaseKind` | Value | Flag | Meaning | +|---|---|---|---| +| `Unknown` | 0 | `IsRetryable` | No outcome could be established; the lease stays active | +| `Released` | 1 | `IsComplete` | Released and confirmed | +| `AlreadyReleased` | 2 | `IsComplete` | The owner reported that the resource was already released or that it held nothing; nothing was done | +| `PartiallyReleased` | 3 | `RequiresManualRecovery` | Part released, part failed; the failed part may remain | +| `Replaced` | 4 | `IsComplete` | A third party replaced the resource; it was left in place | +| `Superseded` | 5 | `IsComplete` | A newer Client registration replaced the lease | +| `ExternallyRemoved` | 6 | `IsComplete` | The resource was already gone | +| `RefusedTargetNotAttached` | 7 | `RequiresManualRecovery` | Refused before any call: no target is selected | +| `RefusedTargetChanged` | 8 | `RequiresManualRecovery` | Refused before any call: another process or process incarnation is selected | +| `RefusedTargetIdentityUnavailable` | 9 | `RequiresManualRecovery` | Refused before any call: the target identity could not be established | +| `RefusedRuntimeChanged` | 10 | `RequiresManualRecovery` | Refused before any call: the Lua runtime that created the resource is gone | +| `CleanupUnconfirmed` | 11 | `RequiresManualRecovery` | A release call began without a confirmed result; it is never retried | +| `CleanupUnavailable` | 12 | `IsRetryable` | No release call could begin; the lease stays active | + +Only `Unknown` and `CleanupUnavailable` are retryable, as in CheatEngine.SDK: a release call that began is never +retried. A retryable lease is retried by a later `Release()` and, at the latest, by the activation cleanup before the +plugin is disabled. A lease that is still incomplete then (a retry that failed again, a refusal, an unconfirmed or +partial cleanup) is reported in the aggregated deactivation failure as a `CheatEngineOperationException` whose failure +has the host effect `CleanupUnconfirmed`; it is never thrown to the code that released or disposed the lease. + +A target-bound lease (an allocation, a value-scan session, an Auto Assembler patch) also ends when the Client observes +that Cheat Engine selected another process, but that release frees nothing. It reaches CheatEngine.SDK after Cheat +Engine already targets the new process, so CheatEngine.SDK refuses it before any Cheat Engine call +(`RefusedTargetChanged` or another refusal, `RequiresManualRecovery`) and consumes its owner: an allocation or a patch +stays in the previous process, the scanner and found list of a session stay in Cheat Engine, no later release can free +them, and the refusal is reported when the plugin is disabled. Release every target-bound lease before selecting another +process. + +`LeaseReleaseOutcome.ToString()` returns only the kind and the effect. + +#### Lua module leases + +A module generated from `[CheatEngineLuaModule]` registers through its bindings' SDK-generated +`TryRegisterLuaFunctions` with the `RejectExisting` collision policy and keeps the CheatEngine.SDK registration lease. +`ILuaModule.Unregister()` releases that lease: CheatEngine.SDK writes an exported Lua global only while it still holds +the value the module installed, compared by primitive identity, and never overwrites a value a third party put there +(audit finding F12, qualification scenario Q16). The returned `LuaModuleReleaseOutcome` copies what the SDK observed: +`Kind` in the Client lease vocabulary (`Released`, `PartiallyReleased`, `RefusedRuntimeChanged` for a registration of an +earlier Lua attachment or state or an admission refused with `Detached` or `ExternalStateReset`, which consumes it, +`AlreadyReleased` when nothing was owned, `CleanupUnavailable` when CheatEngine.SDK refused the Lua admission for another +reason and the module kept its registration, `CleanupUnconfirmed` when CheatEngine.SDK consumed the registration but +reported a release outside its documented shape), `RemovedCount`, `ReplacementCount` (a replaced or +already-`nil` global, left untouched), `RestoredCount` (always `0` for a generated module), `RemainingCount` and the +`FailedExports` of a partial release, which is never retried. A manual `ILuaModule` reports its release with the +`LuaModuleReleaseOutcome` factories. The outcome holds copied names and counts only. `ILuaModuleLease` is an +`ICheatEngineLease`: its `Release()` calls `Unregister()` on Cheat Engine's main thread, keeps the reported outcome in +`LastModuleReleaseOutcome` next to the lease's `LastReleaseOutcome`, and returns the same kind with its host effect +(`Completed` for `Released`, `Started` for `PartiallyReleased` and `CleanupUnconfirmed`, `NotStarted` for a release that +wrote nothing); an exception thrown by a module is `CleanupUnconfirmed`. Only `CleanupUnavailable` and `Unknown` keep +the lease active for a retry. This behavior is covered by managed tests against a double of the SDK registration set +(C1); it is not a host qualification. + +### Diagnostics and redaction + +`CheatEngineFailure.Kind`, `Operation`, and `HostEffect`, together with counts and durations such as +`PatternScanMetrics`, are safe to log. `CheatEngineFailure.Message` and `CheatEngineFailure.Exception`, addresses, +values, symbol expressions, module names, file paths, and Lua source or error text are **user data**: log them only on an +explicit opt-in chosen by the application. `CheatEngineFailure.ToString()` returns only +`"{Kind} in {Operation} (host effect: {HostEffect})"`, so a structured logger that formats the failure object emits no +user data by default. Client libraries never log user data themselves: Hosting events carry epochs, stage names, +counts, and exception type names only, and a test rejects any Client `LoggerMessage` event whose parameters could carry +an address, expression, path, script, message, exception, or failure object. The Core diagnostic events 1000 to 1800 +(runtime snapshots, capability refusals, target-selection changes, pointer-width refusals, batch counts, table +generations, activation and symbol outcomes, scan metrics, Lua durations, cleanup failures, lease releases, and the +warning for an Auto Assembler patch applied after a target change) follow the same rule; the +[`CheatEngine.Client.Core` README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Core/README.md#diagnostics-events) +lists them. + +### Typed memory routes + +`IMemoryClient` has two typed routes, and it never resolves a codec implicitly: + +- **Primitives** (`TryReadPrimitive`, `TryWritePrimitive`, the primitive batches and their throwing forms) take + `where T : unmanaged` and support exactly `sbyte`, `byte`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`, + `double` and `Address` (a target pointer, read and written at the observed bitness). Any other `T` is refused with + `OperationRejected` and `HostEffect.NotStarted` before dispatch, without a Cheat Engine call. +- **Codecs** (`TryRead`, `TryWrite` and their throwing forms): the `MemoryReadRequest` or `MemoryWriteRequest` + carries the `IMemoryCodec` the application built or resolved. The codec's `TryRead` and `TryWrite`, and its + context's `TryReadBytes` and `TryWriteBytes`, report a classified `out CheatEngineFailure failure`: a codec can pass + the context's failure on unchanged, or return the `default` failure to let the Client classify it. + +String requests carry an explicit `MemoryStringEncoding` in their constructors +(`new MemoryStringReadRequest(address, maximumLength, encoding)`, +`new MemoryStringWriteRequest(address, value, maximumLength, encoding)`). The primitive batch outcomes expose +`RequestedCount`, `CompletedCount`, `Failure` and `IsSuccess`; a read outcome's `Values` holds the values read in order, +and `MemoryBatchWriteEffectState` is `Unknown` (0), `NotStarted`, `Partial` or `Completed`. + +### Memory limits and batch effects + +`MemoryResourceLimits` is copied once per activation. A byte, string, or batch request over a budget fails before dispatch +with `OperationRejected` and `HostEffect.NotStarted`. A codec access over a budget fails that codec call before the +access reaches Cheat Engine. The Client uses four terms for these limits: + +- **Maximum block size**: `MaximumReadBytes`, `MaximumWriteBytes`, and `MaximumStringBytes` bound the contiguous block that + one byte, codec, or string operation may copy. A custom codec's context reads and writes are charged cumulatively + against the same budgets during one codec call. +- **Request count per batch**: `MaximumBatchOperationCount` can tighten, but never raise, the hard + `MemoryBatchLimits.MaximumOperationCount` (1024). `MaximumBatchPayloadBytes` also bounds the count multiplied by the + element size. +- **Maximum scratch allocation**: the largest managed buffer the Client allocates for one operation is the byte array of a + byte read (at most `MaximumReadBytes`) or the value array of a batch read (at most `MaximumBatchPayloadBytes`). These + operations allocate nothing in the target process. +- **Partial-effect state**: a batch write runs in order and is never rolled back. + `MemoryPrimitiveBatchWriteOutcome.EffectState` reports `NotStarted`, `Partial` (with `CompletedCount` and + `FailedIndex`), `Completed`, or `Unknown`. + +`MemoryStringReadRequest.MaximumLength` is passed unchanged as Cheat Engine's `readString` `maxlength` argument. Cheat +Engine 7.7 does not document whether it counts bytes or characters, so treat it as a host-side bound. This is still to be +qualified on a live host (C3). For admission, the Client charges it as bytes for UTF-8 and as twice that for UTF-16. + +## Public API charter + +This charter is normative for every public type of the seven Client packages; the 1.x line only adds to it. +`PublicApiCharterTests`, `OutcomeEnumConventionTests`, `PublicClientSignatureBoundaryTests`, +`PublicSurfaceInventoryTests`, `DefaultOutputValueTests` and `OperationNameTests` check its mechanical rules; +`OperationNameTests` reads the operation names that Core, Fluent and Hosting write. + +### Operation forms + +- **`TryX` and `X`.** An operation that can fail for an expected reason has a `TryX` form that returns `bool` with its + value in an `out` parameter and a classified `out CheatEngineFailure failure`, and a throwing `X` form with the same + inputs that returns the same value (or `void`) or throws that failure through `CheatEngineFailure.Throw(token)`. +- **`XDetailed`.** Some operations also return an outcome instead of throwing an expected failure + (`IPatternScanner.ScanDetailed`, `IMemoryClient.ReadBytesDetailed`, `ReadPrimitiveBatchDetailed`, + `WritePrimitiveBatchDetailed`); a Detailed form throws exactly what its `Try` form throws. +- **Parameter order.** Required inputs, then the inputs that are optional in the throwing form (required in the `Try` + form), then the `out` value, then `out CheatEngineFailure failure`, then `CancellationToken cancellationToken = + default`, always last. `ICheatEngineDispatcher` uses explicit overloads instead of an optional token. An operation on + one existing resource takes its identifier first, never inside a request (`TryUpdate(id, update, ...)`, + `TrySetParent(childId, parentId, ...)`). +- **Names.** `Get` returns an `X` (`GetPreviousInstructionAddress`, `GetSelectedRecord`, `GetCurrentProcess`). +- **Outputs.** A failed `Try` leaves its value output `default`. A lease or session output is nullable and annotated + `[NotNullWhen(true)]`. +- **Exemptions.** BCL-shaped pure lookups and parses (`ClientCapabilities.TryGet`, `AobPattern.TryParse`) have no + failure output and no throwing twin. Implementable callbacks (`ILuaOperation.TryExecute`, + `IMemoryCodec.TryRead`, `TryWrite`) and the codec contexts' `TryReadBytes` and `TryWriteBytes` keep the `Try` + shape, `out` failure included, without a token or a twin. + +### Registrations and leases + +- `RegisterX`/`TryRegisterX` installs a named resource that other code can also see, replace or remove (a symbol, a Lua + module) and returns the lease that owns it; its release never removes a resource the lease no longer owns + (`Replaced`, `Superseded`, `ExternallyRemoved`). A resource only its lease can reach is created by an action verb + (`Allocate`, `CreateSession`, `ApplyPatch`). A lease interface is named after the resource it owns. +- Every lease is an `ICheatEngineLease`: `Release()` returns a `LeaseReleaseOutcome`, `Dispose()` never throws, + `IsReleased` says that no later attempt will be made, `LastReleaseOutcome` keeps the last recorded attempt, and + `RequiresManualRecovery` says that what the lease owns may remain and this lease can no longer remove it. A release + of an ended lease returns `LastReleaseOutcome` without calling Cheat Engine. `AlreadyReleased` means only that the + owner reported nothing left to release. +- A target-bound lease (`ITargetMemoryLease`, `IValueScanSession`, `IAutoAssemblerPatchLease`) exposes `SelectionEpoch`. + No lease duplicates a fact of `ICheatEngineLease` or of its outcome. + +### Failures and exceptions + +- `CheatEngineFailure.Kind` says why and `HostEffect` how far the Cheat Engine primitive got. `InvalidState` is + reserved for Client-side state: the activation, a session or resource, or a target fact the operation requires. A + host rollback or release that was not confirmed is `IndeterminateHostResult` (or the kind of the failure that caused + it) with `CleanupUnconfirmed`. A CheatEngine.SDK outcome or status the Client does not recognize fails closed as + `IndeterminateHostResult`, never as a success; `Unknown` is left for an exception that cannot be classified. +- The exception type depends only on the kind: `Cancelled` → `CheatEngineOperationCanceledException`; + `ActivationExpired` → `CheatEngineActivationExpiredException`; `InvalidState` → + `CheatEngineInvalidStateException`; any other kind → `CheatEngineOperationException`. No Client exception has a + public constructor: `CheatEngineFailure.Throw(token)` throws one and `CheatEngineFailure.ToException(token)` creates + one, so every exception keeps the complete failure, including `HostEffect` (`NotStarted` for an admission refusal). +- A null argument, a `default` (uninitialized) request, an undefined enum value or an out-of-range number is a + programming error. The `Try` form and the throwing form both throw an `ArgumentException` for it, before the + activation check and before any Cheat Engine call, as the BCL validates arguments first; it is never returned as a + failure. A null argument throws `ArgumentNullException` and an undefined enum value or an out-of-range number + `ArgumentOutOfRangeException`, as the argument's own constructor or factory does; a `default` request throws + `ArgumentException` or one of these two, depending on the first field its check meets. +- A well-formed request that the Client refuses before calling Cheat Engine fails with `NotStarted`: + `CapabilityUnavailable` when the activation did not enable the capability (unsafe Lua or Auto Assembler patches + without their opt-in, table files without an allowed root), and `OperationRejected` when a policy or a budget + refuses the request (a path outside the trusted table roots, a resource limit, an unsupported primitive `T`), when + the state it meets refuses it (a next-scan value of another type than the session's first scan, a module smaller + than the pattern), or when its values, each valid, cannot be served together (a self-parent, a range without room + for a whole match). A limit of Cheat Engine's own, such as a page beyond its 32-bit result index, is + `ResultLimitExceeded`. +- A `Try` form that needs the activation checks it after its arguments and before it returns any failure: an ended + activation throws `CheatEngineActivationExpiredException` and a stopping one `CheatEngineInvalidStateException` + (outside the deactivation callbacks of the failure contract, which can still work on existing state), whatever + refusal the request would meet. That check is named after the operation: the exception's + `Failure.Operation` is the operation's own `.`, not the `Dispatcher.Invoke` of the dispatcher it + would have used. +- `CheatEngineFailure.Operation` is `.`. `Service` is the `ICheatEngineClient` property that exposes + the service, `UnsafeLua` or `AutoAssembler` for the services only dependency injection registers, or `Client` for + the activation itself (`Client.Activate`, and `Client.GetRequiredClient` for the plugin member of that name). + `Member` is the public method the caller invoked, without `Try` or `Detailed`. A lease release is + `.Release`, and a failure raised inside a codec or a Lua operation context names the call that runs it. + `Operation` is safe to log; like `Message`, its text is not a compatibility contract. A failure that the dispatcher + itself reports while it runs an operation's work (a cancellation observed at dispatch admission, a main-thread + invocation that failed, an activation that ended meanwhile) can still carry the dispatcher's `Dispatcher.Invoke`. +- No `Try` form throws a CheatEngine.SDK exception. `CheatEngineFailure.Exception` may hold one: its type is not part + of this contract and changes with the SDK, so never type-test it. +- "Cancelled" is the Client's spelling for the failure kind and the host outcome; exception type names follow the BCL. + +### Value types and outcomes + +| Suffix | Meaning | Construction | +|---|---|---| +| `*Request`, `*Definition`, `*Update`, `*Search`, `*Registration`, `*Script`, `*Descriptor` | A validated input | One public constructor; named factories only for per-kind invariants (`ScanAlignment.AlignedTo`, `ValueScanFirstRequest.Exact`) | +| `*Snapshot` | An immutable copy of host state | Never settable | +| `*Info` | A group of facts inside `CheatEngineRuntimeSnapshot` | — | +| `*Result` | The value a successful call returns | — | +| `*Outcome` | What a Detailed form or a release reports; never an enum | — | +| `*Metrics` | Counts and durations, safe to log | — | + +- A **Detailed outcome** exposes `IsSuccess` (`Failure` is `null`), `Failure`, the payload under the name of its `Try` + form's `out` value (`Result`, `Bytes`, `Values`) and its facts. The **release outcome** of a lease, + `LeaseReleaseOutcome`, exposes `Kind`, `HostEffect` and exactly one of `IsComplete`, `IsRetryable` and + `RequiresManualRecovery`; `IsComplete` belongs to release outcomes only. `LuaModuleReleaseOutcome` is the report an + `ILuaModule.Unregister` implementation returns: it carries `Kind`, `IsComplete` and the module's counts, and the + Client maps it into the `LeaseReleaseOutcome` of the module's lease. +- Public value types are `readonly struct`s with get-only properties and an explicit constructor whose parameters are + camelCase; there are no `init` accessors and no positional records. A `record struct` is used only when member-wise + equality is meaningful. Each member compares with its own equality, so `CheatEngineFailure` compares its `Exception` + by reference; a type that holds an `ImmutableArray`, which also compares by reference, is a plain `readonly struct`. +- Every value a public member returns or a `Try` form publishes is safe to read at `default`: reference members are + empty (never `null`) and `ImmutableArray` members are empty. BCL outputs (`ImmutableArray`, `string?`) keep the + BCL's default. +- Counts end in `Count` (`RecordCount`, `ResultCount`, `RequestedCount`, `CompletedCount`) and byte lengths in `Length` + (`RequestedLength`, `ConfirmedLength`). Bounds are `Maximum`: `MaximumItems` fails with `ResultLimitExceeded`, + `MaximumResults` truncates and sets `IsTruncated`, `MaximumCount` bounds a page that reports `HasMore`, + `MaximumBytes` and `MaximumCount` are budgets, and `MaximumLength` is a host string bound. A truncated host + text has a `Truncated` companion flag. + +### Width vocabulary + +- `PointerSize` is the only width type: `ProcessSnapshot.Bitness`, `CheatEngineRuntimePlatformInfo.TargetBitness` and + `CheatEngineBitness`, `IMemoryReadContext.Bitness` and `IMemoryWriteContext.Bitness`. It is `Unknown` when not + observed and is never inferred from the ISA or the configured size. +- `ConfiguredPointerSizeBytes` (`int?`, the raw `getPointerSize()`) and `ConfiguredPointerSize` (its `PointerSize` + projection) are reported together; `ConfiguredPointerSizeDiffersFromBitness` is `bool?` everywhere, `null` when + either value is unknown. +- The ISA is `CheatEngineArchitecture`. Platform facts name their subject: `Host*`, `CheatEngine*`, `Target*`. + +### Enums + +- Every public enum is backed by `int`, declares every value explicitly and defines zero. A value never changes + meaning and a minor release can add values: handle an unrecognized value like `Unknown`. +- An **outcome enum** reports what happened or was observed: `Unknown = 0`, and its name ends in `Kind`, `Status`, + `State`, `Effect` or `Scope` (`PatternScanRouteReason` and `ClientCapabilityEvidenceReasonCode` are the listed + exceptions). An **option enum** is the caller's choice: its zero is the default choice and its name never ends in an + outcome suffix (`Mode`, `Preference`, `Protection`, `Encoding`, `Requirement`, `Comparison`, `Type`). No enum is + named `*Outcome`. + +### Call-only and Implementable interfaces + +The documentation of every public interface says whether it is **Call-only** (the Client implements it and applications +call it; a 1.x minor release can add members, so implement it only in a test double) or **Implementable** +(applications implement it and the Client calls it; its members are frozen for 1.x): `ILuaModule`, +`ILuaOperation`, `ILuaResultMapper`, `IMemoryCodec` and `ICheatEngineClientModule`. + +### CheatEngine.SDK types in public signatures + +Only these descriptive CheatEngine.SDK values appear in public signatures: `Address`, `TargetProcessId`, `ModuleName`, +`ModuleInfo`, `ModuleSectionInfo`, `MemoryRegionInfo`, `SymbolExpression`, `SymbolInfo`, `MemoryRecordId`, +`PointerSize`, `CheatEngineArchitecture`, `TargetAbi`, `CheatEngineVersion`, `VariableType`, `TargetBackend` and +`CheatEngineOperatingSystem`. No SDK outcome, status or kind type and no SDK exception type appears in a public +signature. Because these types are part of the Client's signatures, moving to CheatEngine.SDK 3.0 is a Client 2.0. + +### Shared vocabulary + +| Concept | Client name | Not | +|---|---|---| +| Activation epoch | `Epoch` (`ICheatEngineClient`, `ICheatEngineRuntime`, `CheatEngineRuntimeSnapshot`, `ILuaExecutionContext`) | "SDK lifecycle epoch", an epoch on a lease | +| Target-selection epoch | `SelectionEpoch` (`ProcessSnapshot`, every target-bound lease) | — | +| Lua runtime replaced | `RuntimeChanged`, `RefusedRuntimeChanged` | `RuntimeInvalidated` | +| No target selected | `TargetNotAttached`, `RefusedTargetNotAttached` | `RefusedNoTarget` | +| Another target selected | `TargetChanged`, `RefusedTargetChanged` | — | +| Target identity not established | `TargetIdentityUnavailable` | — | +| Effect ran to completion | `Completed` (`CheatEngineHostEffect`, `MemoryBatchWriteEffectState`) | `Complete` | +| Size of the request | `RequestedCount`, `RequestedLength` | `AttemptedCount` | +| Done so far | `CompletedCount`, `ConfirmedLength` | — | +| Last release attempt | `LastReleaseOutcome` (every lease), `LastModuleReleaseOutcome` (what a Lua module reported) | `ModuleReleaseOutcome` | +| Rows the host reported | `ResultCount` (value scans), `HostResultCount` (AOB), `RecordCount` (tables) | `TotalCount` | +| Local operating-system catalog | `Local*` (`LocalProcessId`, `LocalProcessSnapshot`, `GetLocalProcesses`) | `ProcessInfo*` | +| Cheat Engine's target | `Process*` (`ProcessSnapshot`, `IProcessClient`) | — | +| AOB request data / scanner report | `Aob*` / `PatternScan*` | — | +| Value scans | `ValueScan*`, `ICheatEngineClient.ValueScans` | `Scans` | +| Cut result / cut host text / partial page | `IsTruncated` / `Truncated` / `HasMore` | — | +| Host text (user data) | `Host` (`HostMessages`, `HostWarnings`) | — | +| Why, as text / as a typed value | `Reason`, `EffectiveReason` / `EffectiveReasonCode`, `RouteReason` | — | +| AOB range / value-scan range | `Start`–`End` (End is the last allowed match start) / `StartAddress`–`StopAddress` (Stop is exclusive) | — | diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs deleted file mode 100644 index fac13de..0000000 --- a/libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.RemoteExecution; - -/// Injects existing DLLs and executes bounded target calls without exposing target allocation handles. -public interface IRemoteExecutionClient -{ - /// Tries to inject an existing absolute DLL path into the selected target process. - public bool TryInjectLibrary(RemoteDllInjectionRequest request, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Injects an existing absolute DLL path or throws when the host rejects it. - public void InjectLibrary(RemoteDllInjectionRequest request, CancellationToken cancellationToken = default); - - /// Tries to call a target function with a positive timeout and copied parameter result data. - public bool TryInvoke(RemoteCallRequest request, out RemoteCallResult result, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Calls a target function or throws when remote execution fails. - public RemoteCallResult Invoke(RemoteCallRequest request, CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs deleted file mode 100644 index 64d3b71..0000000 --- a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Immutable; - -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.RemoteExecution; - -/// Describes one bounded remote call and its copied parameter payload. -public readonly record struct RemoteCallRequest -{ - /// Creates a remote-call request. - /// is not strictly positive. - public RemoteCallRequest(Address entryPoint, ReadOnlySpan parameters, TimeSpan timeout) - { - if (timeout <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(timeout), "A remote-call timeout must be strictly positive."); - } - - EntryPoint = entryPoint; - Parameters = ImmutableArray.Create(parameters); - Timeout = timeout; - } - - /// Gets the target function entry-point address. - public Address EntryPoint - { - get; - } - - /// Gets an immutable copy of the call parameter bytes. - public ImmutableArray Parameters - { - get; - } - - /// Gets the strictly positive call timeout. - public TimeSpan Timeout - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs deleted file mode 100644 index 71697bf..0000000 --- a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Immutable; - -namespace CheatEngine.Client.RemoteExecution; - -/// Contains the copied result returned by a completed remote call. -public readonly record struct RemoteCallResult -{ - /// Creates a copied remote-call result. - public RemoteCallResult(ulong returnValue, ReadOnlySpan output) - { - ReturnValue = returnValue; - Output = ImmutableArray.Create(output); - } - - /// Gets the target ABI return value represented as an unsigned machine value. - public ulong ReturnValue - { - get; - } - - /// Gets an immutable copy of any requested output parameter bytes. - public ImmutableArray Output - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs deleted file mode 100644 index 6f37967..0000000 --- a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace CheatEngine.Client.RemoteExecution; - -/// Describes a DLL whose existing absolute path is injected into the selected target. -public readonly record struct RemoteDllInjectionRequest -{ - /// Creates a remote DLL-injection request. - /// is blank, relative, or not a DLL path. - public RemoteDllInjectionRequest(string libraryPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(libraryPath); - if (!Path.IsPathFullyQualified(libraryPath)) - { - throw new ArgumentException("A DLL injection path must be absolute.", nameof(libraryPath)); - } - - if (!string.Equals(Path.GetExtension(libraryPath), ".dll", StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("A DLL injection path must have a .dll extension.", nameof(libraryPath)); - } - - LibraryPath = libraryPath; - } - - /// Gets the absolute DLL path. The implementation verifies that it exists before injection. - public string LibraryPath - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs index 8466e60..3f40db1 100644 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs @@ -1,11 +1,19 @@ namespace CheatEngine.Client.Results; -/// Thrown when a resource is used after its plugin activation epoch has ended. +/// +/// Thrown for a failure: a client, lease or context is used +/// after its plugin activation ended. +/// +/// +/// throws it and +/// creates it; it has no public constructor. +/// public sealed class CheatEngineActivationExpiredException : CheatEngineClientException { - /// Creates an expired-activation exception for the attempted operation. - public CheatEngineActivationExpiredException(string operation, string message, Exception? innerException = null) - : base(new CheatEngineFailure(CheatEngineFailureKind.ActivationExpired, operation, message, innerException)) + /// Creates the exception from an activation-expired failure without losing its host effect. + /// A failure whose kind is . + internal CheatEngineActivationExpiredException(CheatEngineFailure failure) + : base(failure) { } } diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs index 777cb35..19cd9d2 100644 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs @@ -1,16 +1,27 @@ namespace CheatEngine.Client.Results; -/// Base exception for an invalid use of the Cheat Engine client contract. -public class CheatEngineClientException : Exception +/// +/// Base type of the Client exceptions that carry a classified ; a +/// failure throws , +/// an , instead. +/// +/// +/// No Client exception has a public constructor: throws +/// one and creates one, so the exception type always +/// follows and every exception keeps the complete failure, including its +/// . +/// +public abstract class CheatEngineClientException : Exception { /// Creates an exception from a classified client failure. - public CheatEngineClientException(CheatEngineFailure failure) + /// The failure the exception carries. + private protected CheatEngineClientException(CheatEngineFailure failure) : base(failure.Message, failure.Exception) { Failure = failure; } - /// Gets the failure that caused the exception. + /// Gets the failure that caused the exception, including its host effect. public CheatEngineFailure Failure { get; diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs deleted file mode 100644 index a935b8b..0000000 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CheatEngine.Client.Results; - -/// Thrown when code uses a client, session, or handle outside its active plugin lifecycle. -public sealed class CheatEngineClientLifecycleException : CheatEngineClientException -{ - /// Creates a lifecycle exception with the operation that was attempted. - public CheatEngineClientLifecycleException(string operation, string message, Exception? innerException = null) - : base(new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, message, innerException)) - { - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs index 1dfe257..38daed8 100644 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs @@ -1,57 +1,207 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + namespace CheatEngine.Client.Results; /// An immutable description of an expected Cheat Engine operation failure. +/// +/// +/// classifies why the operation failed and states how far the +/// requested Cheat Engine primitive got. Both are stable, language-independent values: never classify a failure by +/// parsing or text. +/// +/// +/// Diagnostics and redaction (Q46). , and +/// are safe to log. and are user data: +/// they can contain addresses, values, symbol expressions, module names, file paths, or Lua source and error text. +/// Log them only on an explicit opt-in chosen by the application. returns only the safe +/// fields, so a structured logger that formats the failure object does not emit user data by default. +/// +/// public readonly record struct CheatEngineFailure { - /// Creates a failure while retaining an optional SDK exception for diagnostics. + private readonly string? _message; + private readonly string? _operation; + + /// Creates a failure that states its category, the failed operation and what is known of its host effect. + /// The stable failure category. + /// The Client operation that failed, for example Patterns.Scan. + /// A human-readable diagnostic message; it may contain user data. + /// The originating exception, if any; it may contain user data. + /// + /// What is known about the Cheat Engine side effect of the failed operation; the conservative + /// when omitted. + /// + /// + /// or is , empty or white space. + /// + /// + /// or is not a defined value. + /// public CheatEngineFailure(CheatEngineFailureKind kind, string operation, string message, - Exception? exception = null) + Exception? exception = null, CheatEngineHostEffect hostEffect = CheatEngineHostEffect.Unknown) { ArgumentException.ThrowIfNullOrWhiteSpace(operation); ArgumentException.ThrowIfNullOrWhiteSpace(message); + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind), kind, "The failure kind must be a defined value."); + } + + if (!Enum.IsDefined(hostEffect)) + { + throw new ArgumentOutOfRangeException(nameof(hostEffect), hostEffect, + "The Cheat Engine host effect must be a defined value."); + } + Kind = kind; - Operation = operation; - Message = message; + _operation = operation; + _message = message; Exception = exception; + HostEffect = hostEffect; } /// Gets the stable failure category. + /// for the value. public CheatEngineFailureKind Kind { get; } - /// Gets the client operation that failed. - public string Operation + /// Gets the Client operation that failed. + /// + /// The name is <Service>.<Member>: Service is the + /// property that exposes the service (UnsafeLua or AutoAssembler for the services only dependency + /// injection registers, Client for the activation itself), and Member is the public method the caller + /// invoked, without Try or Detailed, for example Memory.ReadBytes. A lease release is + /// <Service>.Release, and a failure raised inside a memory codec or a Lua operation context names the + /// call that runs it (Memory.Read, Memory.Write, Lua.Execute). The name is safe to log; + /// like , its text is not a compatibility contract. Never : + /// for the value. + /// + public string Operation => _operation ?? string.Empty; + + /// Gets a human-readable diagnostic message. + /// + /// The message may contain user data (addresses, expressions, paths, Lua text); do not log it by default. Its text + /// is not part of the contract and can change in any release. Never : + /// for the value. + /// + public string Message => _message ?? string.Empty; + + /// Gets whether this value is the failure, which describes no failure. + /// + /// No Client operation returns the value as a failure: a Try method leaves its + /// failure output only when it returns . Reading a + /// value is safe: and are empty, + /// and are Unknown, and is + /// . Only and + /// reject it. + /// + public bool IsDefault => _operation is null; + + /// Gets the originating exception when one exists. + /// + /// It may be a CheatEngine.SDK exception, whose type is not part of the Client contract and changes with the SDK: + /// never type-test it. It may contain user data; do not log it by default. + /// + public Exception? Exception { get; } - /// Gets a human-readable diagnostic message. - public string Message + /// Gets what is known about the Cheat Engine side effect of the failed operation. + /// + /// is the conservative default: any effect is possible. A cancellation + /// token never interrupts a Cheat Engine call that has already started. + /// + public CheatEngineHostEffect HostEffect { get; } - /// Gets the originating SDK exception when one exists. - public Exception? Exception + /// Throws this failure as the exception its kind maps to. + /// + /// The token the failed operation observed. It is recorded on the thrown + /// when the failure is + /// , so that a caller can match it with the token it passed. + /// + /// + /// It throws the exception that creates. Every throwing convenience + /// operation of the Client throws through this method. + /// + /// + /// The failure is . + /// + /// + /// The failure is . + /// + /// + /// The failure is . + /// + /// The failure has any other kind. + /// The failure is the value. + [DoesNotReturn] + [StackTraceHidden] + public readonly void Throw(CancellationToken cancellationToken = default) { - get; + throw ToException(cancellationToken); } - /// Throws this failure as an operation exception. - public readonly void Throw() + /// Creates, without throwing it, the exception that throws. + /// + /// The token the failed operation observed, recorded on a . + /// + /// + /// The exception whose type depends only on , and whose Failure equals this failure, + /// including its : + /// + /// + /// : , an + /// . + /// + /// + /// : + /// . + /// + /// + /// : . + /// + /// + /// Every other kind, including a kind this version does not define: + /// . + /// + /// + /// + /// + /// This and are the only public ways to obtain a Client exception: none + /// has a public constructor. Use it to hand a failure to code that expects an exception, for example a + /// . + /// + /// + /// The failure is the value, which describes no failure. + /// + public readonly Exception ToException(CancellationToken cancellationToken = default) { - if (Kind == CheatEngineFailureKind.ActivationExpired) + if (IsDefault) { - throw new CheatEngineActivationExpiredException(Operation, Message, Exception); + throw new InvalidOperationException( + "A default CheatEngineFailure describes no failure and has no exception."); } - if (Kind == CheatEngineFailureKind.InvalidState) + return Kind switch { - throw new CheatEngineClientLifecycleException(Operation, Message, Exception); - } + CheatEngineFailureKind.Cancelled => new CheatEngineOperationCanceledException(this, cancellationToken), + CheatEngineFailureKind.ActivationExpired => new CheatEngineActivationExpiredException(this), + CheatEngineFailureKind.InvalidState => new CheatEngineInvalidStateException(this), + _ => new CheatEngineOperationException(this) + }; + } - throw new CheatEngineOperationException(this); + /// Returns only the fields that are safe to log: kind, operation, and host effect. + /// A redaction-safe description that never contains or . + public override string ToString() + { + return $"{Kind} in {(IsDefault ? "" : Operation)} (host effect: {HostEffect})"; } } diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs index 6d0bef9..5c39bc6 100644 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs @@ -6,7 +6,11 @@ public enum CheatEngineFailureKind /// The operation could not be classified more precisely. Unknown = 0, - /// The operation was cancelled before a Cheat Engine call began. + /// + /// The caller's cancellation was observed. tells whether Cheat Engine + /// work had started; a token never interrupts a Cheat Engine call that has already begun and never removes an + /// effect that such a call produced. + /// Cancelled = 1, /// The required Cheat Engine capability or Lua global is unavailable. @@ -49,5 +53,48 @@ public enum CheatEngineFailureKind ActivationExpired = 14, /// The operation is not valid in the resource's current lifecycle or session state. - InvalidState = 15 + InvalidState = 15, + + /// + /// Cheat Engine returned a result that the consumed CheatEngine.SDK version cannot attribute to one cause: several + /// documented causes (for example no match and a host failure) are indistinguishable. Inspect the operation's + /// documentation; never treat it as absence. + /// + /// + /// This is distinct from (several matches were observed), from + /// (a result outside the documented shape was observed), and from + /// (absence was established). + /// + IndeterminateHostResult = 16, + + /// + /// The target process that the operation or resource was bound to is no longer the target Cheat Engine has + /// selected: the selection moved to another process, or the same process identifier now names another process + /// incarnation. Nothing is retried against the new target. + /// + /// + /// This is distinct from (no target is selected at all) and from + /// (the identity of the current target could not be established). + /// + TargetChanged = 17, + + /// + /// The operation had to verify the identity of Cheat Engine's current target and could not establish it, so it + /// was refused instead of running against a target it cannot vouch for. + /// + /// + /// The target may be unchanged: this kind reports missing evidence, never a proven change (which is + /// ). + /// + TargetIdentityUnavailable = 18, + + /// + /// The Cheat Engine Lua runtime that the operation or resource depends on is no longer current: Cheat Engine + /// replaced its Lua state outside the plugin's control, or the resource belongs to an earlier Lua attachment. + /// Resources created before the change are refused; disabling and re-enabling the plugin recovers. + /// + /// + /// This is distinct from , which reports that the Client activation itself ended. + /// + RuntimeChanged = 19 } diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineHostEffect.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineHostEffect.cs new file mode 100644 index 0000000..8110cce --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineHostEffect.cs @@ -0,0 +1,60 @@ +namespace CheatEngine.Client.Results; + +/// Describes what is known about the Cheat Engine side effect of an operation that failed. +/// +/// +/// A classifies why an operation failed through +/// . This value independently states how far the requested Cheat Engine +/// primitive got, so a caller can decide whether a retry, a compensation, or an investigation is required. The +/// two facts are deliberately separate: for example a failure can +/// be (the token was observed before dispatch) or (the token was +/// observed while Core copied a result that Cheat Engine had already produced). +/// +/// +/// A never interrupts a Cheat Engine call that has started and +/// never removes an effect that a started call produced. +/// +/// +public enum CheatEngineHostEffect +{ + /// + /// The Client cannot state whether the requested Cheat Engine primitive ran. Treat any effect as possible. + /// + Unknown = 0, + + /// + /// The requested Cheat Engine primitive was not invoked: the failure was observed during request validation, + /// activation admission, pre-dispatch cancellation, policy evaluation, or a prerequisite lookup. + /// + NotStarted = 1, + + /// + /// The requested Cheat Engine primitive was invoked, but neither its completion nor a rollback was established. + /// Its effects may persist in Cheat Engine or in the target. + /// + Started = 2, + + /// + /// The requested Cheat Engine primitive returned, and the failure happened afterwards inside the Client (result + /// validation, copying, parsing, filtering, or cancellation observed between Client-managed steps). No Cheat + /// Engine resource created for the call remains. + /// + Completed = 3, + + /// + /// Cheat Engine work left a resource or change whose release or rollback could not be confirmed. A resource may + /// remain allocated, or a partial change may remain visible, until Cheat Engine or the target releases it. + /// + CleanupUnconfirmed = 4, + + /// + /// The requested Cheat Engine primitive ran and returned its documented negative result, which establishes that + /// the effect did not happen (for example an allocation that returned nil, or a change the host refused). + /// Nothing was applied, so nothing has to be released or rolled back. + /// + /// + /// This is distinct from (the primitive was never invoked) and from + /// (a refusal that does not prove the absence of a change). + /// + NotApplied = 5 +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineInvalidStateException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineInvalidStateException.cs new file mode 100644 index 0000000..6bf4138 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineInvalidStateException.cs @@ -0,0 +1,19 @@ +namespace CheatEngine.Client.Results; + +/// +/// Thrown for a failure: the operation is not valid in the current +/// activation, session, resource or target state. +/// +/// +/// throws it and +/// creates it; it has no public constructor. +/// +public sealed class CheatEngineInvalidStateException : CheatEngineClientException +{ + /// Creates the exception from an invalid-state failure without losing its host effect. + /// A failure whose kind is . + internal CheatEngineInvalidStateException(CheatEngineFailure failure) + : base(failure) + { + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationCanceledException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationCanceledException.cs new file mode 100644 index 0000000..1f12f3e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationCanceledException.cs @@ -0,0 +1,35 @@ +namespace CheatEngine.Client.Results; + +/// +/// Thrown by a throwing convenience operation when the caller's cancellation was observed: the operation failed with +/// . +/// +/// +/// +/// The exception derives from , so the usual +/// catch (OperationCanceledException) handling applies, and +/// is the token the operation observed. +/// +/// +/// keeps the complete classified failure. Its +/// tells whether Cheat Engine work had started: a token never interrupts a Cheat Engine call that has already +/// begun and never removes an effect that such a call produced. +/// +/// +public sealed class CheatEngineOperationCanceledException : OperationCanceledException +{ + /// Creates the exception from a cancelled failure and the token the operation observed. + /// A failure whose kind is . + /// The token the operation observed. + internal CheatEngineOperationCanceledException(CheatEngineFailure failure, CancellationToken cancellationToken) + : base(failure.Message, failure.Exception, cancellationToken) + { + Failure = failure; + } + + /// Gets the cancelled failure, including its host effect. + public CheatEngineFailure Failure + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs index c1902da..2045ec7 100644 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs @@ -1,10 +1,20 @@ namespace CheatEngine.Client.Results; -/// Thrown when a throwing convenience operation observes an expected Cheat Engine failure. +/// +/// Thrown for every failure kind other than , +/// and , +/// including a kind this version does not define: a throwing convenience operation observed an expected Cheat Engine +/// failure. +/// +/// +/// throws it and +/// creates it; it has no public constructor. +/// public sealed class CheatEngineOperationException : CheatEngineClientException { - /// Creates an operation exception from a classified failure. - public CheatEngineOperationException(CheatEngineFailure failure) + /// Creates an operation exception from a classified failure without losing its host effect. + /// The failure the exception carries. + internal CheatEngineOperationException(CheatEngineFailure failure) : base(failure) { } diff --git a/libs/CheatEngine.Client.Abstractions/Results/LeaseReleaseKind.cs b/libs/CheatEngine.Client.Abstractions/Results/LeaseReleaseKind.cs new file mode 100644 index 0000000..9b9f9ed --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/LeaseReleaseKind.cs @@ -0,0 +1,87 @@ +namespace CheatEngine.Client.Results; + +/// Classifies one attempt to release a Client lease (). +/// +/// +/// The kind is the one vocabulary of every Client lease, whatever CheatEngine.SDK primitive backs it. +/// derives from it whether the release is complete, whether the lease can +/// still be released later, and whether something may remain that only manual recovery can remove. +/// +/// +/// is the zero value, so an outcome that was never assigned never reads as a release. +/// Values never change meaning; new values can be added in a minor release, so handle an unrecognized value like +/// . +/// +/// +public enum LeaseReleaseKind +{ + /// + /// No release outcome could be established: the value of . The lease stays active and + /// a later release can try again. + /// + Unknown = 0, + + /// The resource was released and Cheat Engine confirmed it. + Released = 1, + + /// + /// The owner reported that the resource was already released or that it held nothing; nothing was done. A + /// repeated release of an ended lease does not report this kind: it returns the outcome that ended the lease. + /// + AlreadyReleased = 2, + + /// + /// Part of the resource was released and at least one part failed; the failed parts may remain and are not + /// retried. + /// + PartiallyReleased = 3, + + /// + /// A third party replaced the resource (for example a symbol name now bound to another address), so the lease left + /// it in place and released nothing. + /// + Replaced = 4, + + /// A newer registration made through the Client replaced the lease, so nothing was left to release. + Superseded = 5, + + /// The resource was already gone: something outside the lease removed it, so nothing was released. + ExternallyRemoved = 6, + + /// + /// Cleanup was refused before any Cheat Engine call because Cheat Engine has no selected target, the fact that + /// reports for an operation. The resource may remain in + /// the target it was created in. + /// + RefusedTargetNotAttached = 7, + + /// + /// Cleanup was refused before any Cheat Engine call because the selected target is no longer the process, or the + /// process incarnation, that the resource belongs to. The resource may remain in that process. + /// + RefusedTargetChanged = 8, + + /// + /// Cleanup was refused before any Cheat Engine call because the identity of the selected target could not be + /// established. The resource may remain. + /// + RefusedTargetIdentityUnavailable = 9, + + /// + /// Cleanup was refused before any Cheat Engine call because the Lua runtime that created the resource is no longer + /// current (the plugin was re-enabled or Cheat Engine replaced its Lua state). The resource may remain. + /// + RefusedRuntimeChanged = 10, + + /// + /// A Cheat Engine release call began but its result was not confirmed. The resource may remain, and the call is + /// not retried because it may have had an effect. + /// + CleanupUnconfirmed = 11, + + /// + /// No release call could begin (for example Cheat Engine could not admit the work now, or dispatch was closed). + /// Nothing happened, the lease stays active, and a later release, or the activation cleanup, tries again. + /// + CleanupUnavailable = 12 +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/LeaseReleaseOutcome.cs b/libs/CheatEngine.Client.Abstractions/Results/LeaseReleaseOutcome.cs new file mode 100644 index 0000000..5cf77bd --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/LeaseReleaseOutcome.cs @@ -0,0 +1,120 @@ +namespace CheatEngine.Client.Results; + +/// The copied result of one attempt to release a Client lease (). +/// +/// +/// says what the attempt did and how far the Cheat Engine release +/// call got. The three flags are derived from and exactly one of them is +/// : +/// +/// +/// +/// Flag +/// Kinds +/// +/// +/// +/// +/// , , +/// , , +/// +/// +/// +/// +/// +/// +/// , , and any +/// kind this version does not define +/// +/// +/// +/// +/// +/// , the four Refused* kinds, +/// +/// +/// +/// +/// +/// The value is with an +/// effect: it never reads as a release. returns +/// only the kind and the effect, which are safe to log. +/// +/// +public readonly record struct LeaseReleaseOutcome +{ + /// Creates a release outcome. + /// What the release attempt did. + /// How far the Cheat Engine release call got. + /// + /// or is not a defined value. + /// + public LeaseReleaseOutcome(LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind), kind, "The lease release kind must be a defined value."); + } + + if (!Enum.IsDefined(hostEffect)) + { + throw new ArgumentOutOfRangeException(nameof(hostEffect), hostEffect, + "The Cheat Engine host effect must be a defined value."); + } + + Kind = kind; + HostEffect = hostEffect; + } + + /// Gets what the release attempt did. + public LeaseReleaseKind Kind + { + get; + } + + /// Gets how far the Cheat Engine release call got. + /// + /// when no release call was made (a refusal, an unavailable + /// cleanup, a lease that was already released or replaced), for a + /// confirmed release, when a call began without a confirmed result, + /// and when nothing is known. + /// + public CheatEngineHostEffect HostEffect + { + get; + } + + /// + /// Gets whether the release ended and nothing that the lease owned is known to remain: the lease needs no further + /// action. + /// + public bool IsComplete => Kind is LeaseReleaseKind.Released or LeaseReleaseKind.AlreadyReleased + or LeaseReleaseKind.Replaced or LeaseReleaseKind.Superseded or LeaseReleaseKind.ExternallyRemoved; + + /// + /// Gets whether the release ended but the resource, or part of it, may remain in Cheat Engine or in the target, + /// and the Client will not try again: only a manual recovery (or the end of the target process) removes it. + /// + public bool RequiresManualRecovery => Kind is LeaseReleaseKind.PartiallyReleased + or LeaseReleaseKind.RefusedTargetNotAttached or LeaseReleaseKind.RefusedTargetChanged + or LeaseReleaseKind.RefusedTargetIdentityUnavailable or LeaseReleaseKind.RefusedRuntimeChanged + or LeaseReleaseKind.CleanupUnconfirmed; + + /// + /// Gets whether nothing was released and the lease is still active: a later + /// can try again, and the activation cleanup tries again before the plugin is disabled. + /// + /// + /// only for and + /// (and a kind this version does not define), the same rule as + /// CheatEngine.SDK: a release call that began is never retried. + /// + public bool IsRetryable => !IsComplete && !RequiresManualRecovery; + + /// Returns only the kind and the host effect, which are safe to log. + /// A description in the form Kind (host effect: HostEffect). + public override string ToString() + { + return $"{Kind} (host effect: {HostEffect})"; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs index f107d25..f6b4523 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs @@ -2,55 +2,176 @@ namespace CheatEngine.Client.Runtime; -/// Immutable platform observations captured during one active Cheat Engine activation. +/// Immutable host and target platform observations captured during one active Cheat Engine activation. +/// +/// +/// Every fact is one Cheat Engine global that CheatEngine.SDK read, and none is inferred from another (audit F08): +/// the host operating system and architecture, the bitness of Cheat Engine itself, the target backend, the +/// target ISA, the target bitness, the target ABI, whether the target is Android, and Cheat Engine's configured +/// pointer size. Each fact names its subject (Host, CheatEngine, Target), and a fact that +/// was not observed stays unknown or . +/// +/// +/// Cheat Engine's configured pointer size is per-attachment state: any (re)attach resets it to the target +/// default, so a Client attach silently undoes an earlier override. Cheat Engine's readPointer follows the +/// bitness, not the configured size, and the configured size never changes . +/// +/// public readonly record struct CheatEngineRuntimePlatformInfo { /// Creates runtime platform observations from copied host facts. + /// The operating system Cheat Engine reports (getOperatingSystem). + /// The Cheat Engine host architecture (getSystemArchitecture). + /// + /// The bitness of the Cheat Engine process itself (cheatEngineIs64Bit), or unknown. + /// + /// How Cheat Engine reaches the selected target, or unknown. + /// The target ISA CheatEngine.SDK derived from the family facts, or unknown. + /// The target bitness (targetIs64Bit), or unknown. + /// The target ABI (getABI), or unknown. + /// + /// Whether the target is Android (targetIsAndroid), or when unknown. + /// + /// + /// The raw value of Cheat Engine's configured pointer size, or when it was not observed. + /// Any integer is kept, because Cheat Engine accepts any integer as its configured pointer size. + /// /// - /// does not match the width implied by - /// . + /// and are both known and the bitness + /// differs from the natural width of that architecture (4 bytes for X86 and Arm32, 8 bytes for X64 and Arm64). /// public CheatEngineRuntimePlatformInfo( - CheatEngineArchitecture systemArchitecture, + CheatEngineOperatingSystem hostOperatingSystem, + CheatEngineArchitecture hostArchitecture, + PointerSize cheatEngineBitness, + TargetBackend targetBackend, CheatEngineArchitecture targetArchitecture, - PointerSize targetPointerSize, - TargetAbi targetAbi) + PointerSize targetBitness, + TargetAbi targetAbi, + bool? targetIsAndroid, + int? configuredPointerSizeBytes) { - PointerSize expectedTargetPointerSize = PointerSize.FromArchitecture(targetArchitecture); - if (targetPointerSize != expectedTargetPointerSize) + if (GetNaturalWidth(targetArchitecture) is { } naturalBytes && targetBitness.IsKnown && + targetBitness.Bytes != naturalBytes) { throw new ArgumentException( - "The target pointer size must match the target architecture.", - nameof(targetPointerSize)); + "A known target bitness must match the natural width of a known target architecture.", + nameof(targetBitness)); } - SystemArchitecture = systemArchitecture; + HostOperatingSystem = hostOperatingSystem; + HostArchitecture = hostArchitecture; + CheatEngineBitness = cheatEngineBitness; + TargetBackend = targetBackend; TargetArchitecture = targetArchitecture; - TargetPointerSize = targetPointerSize; + TargetBitness = targetBitness; TargetAbi = targetAbi; + TargetIsAndroid = targetIsAndroid; + ConfiguredPointerSizeBytes = configuredPointerSizeBytes; } - /// Gets the CE host architecture observed from CE's system-architecture global. - public CheatEngineArchitecture SystemArchitecture + /// Gets the operating system Cheat Engine reports it runs on, or unknown. + public CheatEngineOperatingSystem HostOperatingSystem { get; } - /// Gets the target architecture observed by a target-specific probe, or unknown. + /// Gets the Cheat Engine host architecture (getSystemArchitecture), or unknown. + public CheatEngineArchitecture HostArchitecture + { + get; + } + + /// + /// Gets the bitness of the Cheat Engine process itself (cheatEngineIs64Bit): + /// or as observed, when it was not observed; + /// never derived from . + /// + public PointerSize CheatEngineBitness + { + get; + } + + /// + /// Gets how Cheat Engine reaches the selected target: a local process, a file opened as a process, CEServer, or + /// unknown when no target is selected or the backend fact is not established. + /// + public TargetBackend TargetBackend + { + get; + } + + /// + /// Gets the target ISA CheatEngine.SDK derived from Cheat Engine's targetIsX86/targetIsArm family + /// facts and its targetIs64Bit fact; unknown when a fact is missing or the families are contradictory. + /// It is never derived from the 64-bit fact alone. + /// public CheatEngineArchitecture TargetArchitecture { get; } - /// Gets the pointer width implied by the observed target architecture, or unknown. - public PointerSize TargetPointerSize + /// + /// Gets the target bitness (targetIs64Bit, the width Cheat Engine's readPointer follows); not Cheat + /// Engine's configured pointer size. Unknown when no target is selected or the fact was not observed. + /// + public PointerSize TargetBitness { get; } - /// Gets the target ABI observed from CE's ABI global, or unknown. + /// Gets the target ABI (getABI), or unknown. public TargetAbi TargetAbi { get; } + + /// Gets whether the target is Android (targetIsAndroid), or when unknown. + public bool? TargetIsAndroid + { + get; + } + + /// + /// Gets the raw value of Cheat Engine's configured pointer size (getPointerSize) for the current + /// attachment, or when it was not observed. + /// + /// + /// This is per-attachment Cheat Engine state that any (re)attach resets; what it affects besides the value that + /// Cheat Engine reports is not established. It can hold a value other than 4 or 8. + /// + public int? ConfiguredPointerSizeBytes + { + get; + } + + /// + /// Gets Cheat Engine's configured pointer size as a width when it is 4 or 8 bytes; unknown when it was not observed + /// or holds another value. + /// + public PointerSize ConfiguredPointerSize => ConfiguredPointerSizeBytes switch + { + sizeof(uint) => PointerSize.Bit32, + sizeof(ulong) => PointerSize.Bit64, + _ => PointerSize.Unknown + }; + + /// + /// Gets whether Cheat Engine's configured pointer size differs from (audit Q31.a), + /// or when either value is unknown. + /// + public bool? ConfiguredPointerSizeDiffersFromBitness => + ConfiguredPointerSizeBytes is { } configured && TargetBitness.IsKnown + ? configured != TargetBitness.Bytes + : null; + + private static int? GetNaturalWidth(CheatEngineArchitecture architecture) + { + return architecture switch + { + CheatEngineArchitecture.X86 or CheatEngineArchitecture.Arm32 => sizeof(uint), + CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64 => sizeof(ulong), + _ => null + }; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs index 0995fee..8e72d92 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs @@ -1,97 +1,69 @@ -using CheatEngine.SDK.Engine.Runtime; - namespace CheatEngine.Client.Runtime; /// Immutable runtime observations captured during one active Cheat Engine activation. /// -/// is the coarse number returned by Cheat Engine's -/// getCEVersion global. It is deliberately separate from -/// , because that global cannot establish a complete four-part file -/// version. +/// +/// The observations are grouped: (Cheat Engine, Client and CheatEngine.SDK versions), +/// (host and target facts) and (the Client capability +/// evidence). Each fact is what CheatEngine.SDK reported, and a fact that was not observed stays unknown. +/// +/// +/// No snapshot reports an external Lua state reset. Once CheatEngine.SDK detects that Cheat Engine replaced its +/// Lua state outside the plugin's control, it refuses every Lua admission, the snapshot's included: taking a +/// snapshot then fails with , +/// like all other Lua work, and Hosting logs the reset as a warning when it deactivates the plugin (event 8). +/// /// public readonly record struct CheatEngineRuntimeSnapshot { - /// Creates a runtime snapshot from grouped version, platform, and capability observations. + private readonly ClientCapabilities? _capabilities; + + /// Creates a runtime snapshot from grouped observations. + /// The activation epoch. + /// The version observations. + /// The host and target platform observations. + /// The Client capability observations. /// is negative. /// - /// is uninitialized, or a capability collection is . + /// is uninitialized, or is . /// public CheatEngineRuntimeSnapshot( long epoch, - CheatEngineRuntimeVersionInfo versionInfo, - CheatEngineRuntimePlatformInfo platformInfo, - RuntimeCapabilities sdkCapabilities, - ClientCapabilities clientCapabilities) + CheatEngineRuntimeVersionInfo version, + CheatEngineRuntimePlatformInfo platform, + ClientCapabilities capabilities) { ArgumentOutOfRangeException.ThrowIfNegative(epoch); - ArgumentNullException.ThrowIfNull(versionInfo.ClientAssemblyVersion); - ArgumentNullException.ThrowIfNull(versionInfo.SdkAssemblyVersion); + if (version.IsDefault) + { + throw new ArgumentNullException(nameof(version), "Initialized version observations are required."); + } Epoch = epoch; - Version = versionInfo; - Platform = platformInfo; - SdkCapabilities = sdkCapabilities ?? throw new ArgumentNullException(nameof(sdkCapabilities)); - ClientCapabilities = clientCapabilities ?? throw new ArgumentNullException(nameof(clientCapabilities)); + Version = version; + Platform = platform; + _capabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); } - /// Gets the current plugin activation epoch. + /// Gets the activation epoch the snapshot was captured in. public long Epoch { get; } - /// Gets the grouped version observations captured for this activation. + /// Gets the Cheat Engine, Client and CheatEngine.SDK version observations. public CheatEngineRuntimeVersionInfo Version { get; } - /// Gets the grouped platform observations captured for this activation. + /// Gets the host and target platform observations. public CheatEngineRuntimePlatformInfo Platform { get; } - /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. - public double? ObservedCheatEngineVersion => Version.ObservedCheatEngineVersion; - - /// Gets the complete CE build against which this Client release was qualified. - public CheatEngineVersion QualifiedCheatEngineBaseline => Version.QualifiedCheatEngineBaseline; - - /// Gets the assembly version of this Client abstraction assembly. - public Version ClientAssemblyVersion => Version.ClientAssemblyVersion; - - /// Gets the assembly version of the SDK runtime-contract assembly. - public Version SdkAssemblyVersion => Version.SdkAssemblyVersion; - - /// Gets the CE host architecture observed from CE's system-architecture global. - public CheatEngineArchitecture SystemArchitecture => Platform.SystemArchitecture; - - /// Gets the target architecture observed by a target-specific probe, or unknown. - public CheatEngineArchitecture TargetArchitecture => Platform.TargetArchitecture; - - /// Gets the pointer width implied by the observed target architecture, or unknown. - public PointerSize TargetPointerSize => Platform.TargetPointerSize; - - /// Gets the target ABI observed from CE's ABI global, or unknown. - public TargetAbi TargetAbi => Platform.TargetAbi; - - /// Gets the explicit availability observation for each SDK runtime capability that was probed. - public RuntimeCapabilities SdkCapabilities - { - get; - } - - /// Gets the explicit availability observation for each Client high-level capability. - public ClientCapabilities ClientCapabilities - { - get; - } - - /// Gets whether the observed coarse CE version belongs to the qualified major/minor line. - public bool IsOnQualifiedCheatEngineLine => ObservedCheatEngineVersion is { } observed && - observed >= QualifiedCheatEngineBaseline.Major + - QualifiedCheatEngineBaseline.Minor / 10d && - observed < QualifiedCheatEngineBaseline.Major + - (QualifiedCheatEngineBaseline.Minor + 1) / 10d; + /// Gets the explicit availability observation and evidence of each Client capability. + /// for the value. + public ClientCapabilities Capabilities => _capabilities ?? ClientCapabilities.Empty; } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs index 66c1791..978a09c 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs @@ -3,30 +3,76 @@ namespace CheatEngine.Client.Runtime; /// Immutable version observations captured during one active Cheat Engine activation. +/// +/// is the complete four-part file version Cheat Engine reports through +/// getCheatEngineFileVersion, read by CheatEngine.SDK. It is never derived from the coarse floating-point +/// number of getCEVersion, and versions are compared component by component as integers, so 7.10 is not 7.1. +/// identifies the CheatEngine.SDK package actually loaded, and +/// says whether it is exactly the package this Client build was reviewed with. +/// public readonly record struct CheatEngineRuntimeVersionInfo { + /// The version every version property reports for the value: 0.0. + private static readonly Version NoVersion = new(0, 0); + + private readonly Version? _clientAssemblyVersion; + private readonly Version? _sdkAssemblyVersion; + /// Creates runtime version observations from independently observed facts. + /// + /// The complete Cheat Engine file version, or when it was not observed. + /// + /// The complete Cheat Engine build this Client release is qualified on. + /// The assembly version of the Client abstraction assembly. + /// The assembly version of the SDK runtime-contract assembly. + /// + /// The informational version of the loaded CheatEngine.SDK.Engine assembly (its package version and source + /// commit), or when it declares none. + /// + /// + /// Whether the loaded CheatEngine.SDK is exactly the package this Client build consumed and was reviewed with. + /// + /// + /// or is + /// . + /// + /// + /// is empty or white space, or is + /// without a package version. + /// public CheatEngineRuntimeVersionInfo( - double? observedCheatEngineVersion, + CheatEngineVersion? cheatEngineVersion, CheatEngineVersion qualifiedCheatEngineBaseline, Version clientAssemblyVersion, - Version sdkAssemblyVersion) + Version sdkAssemblyVersion, + string? sdkPackageVersion, + bool isReviewedSdkPackage) { - if (observedCheatEngineVersion is { } observed && - (!double.IsFinite(observed) || observed < 0)) + if (sdkPackageVersion is not null && string.IsNullOrWhiteSpace(sdkPackageVersion)) + { + throw new ArgumentException("An SDK package version must be null or non-blank.", nameof(sdkPackageVersion)); + } + + if (isReviewedSdkPackage && sdkPackageVersion is null) { - throw new ArgumentOutOfRangeException(nameof(observedCheatEngineVersion), observed, - "The observed Cheat Engine version must be a finite non-negative number when supplied."); + throw new ArgumentException("A reviewed SDK package requires its package version.", + nameof(isReviewedSdkPackage)); } - ObservedCheatEngineVersion = observedCheatEngineVersion; + CheatEngineVersion = cheatEngineVersion; QualifiedCheatEngineBaseline = qualifiedCheatEngineBaseline; - ClientAssemblyVersion = clientAssemblyVersion ?? throw new ArgumentNullException(nameof(clientAssemblyVersion)); - SdkAssemblyVersion = sdkAssemblyVersion ?? throw new ArgumentNullException(nameof(sdkAssemblyVersion)); + _clientAssemblyVersion = + clientAssemblyVersion ?? throw new ArgumentNullException(nameof(clientAssemblyVersion)); + _sdkAssemblyVersion = sdkAssemblyVersion ?? throw new ArgumentNullException(nameof(sdkAssemblyVersion)); + SdkPackageVersion = sdkPackageVersion; + IsReviewedSdkPackage = isReviewedSdkPackage; } - /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. - public double? ObservedCheatEngineVersion + /// + /// Gets the complete Cheat Engine file version (getCheatEngineFileVersion), or when + /// Cheat Engine did not report one. + /// + public CheatEngineVersion? CheatEngineVersion { get; } @@ -38,14 +84,39 @@ public CheatEngineVersion QualifiedCheatEngineBaseline } /// Gets the assembly version of this Client abstraction assembly. - public Version ClientAssemblyVersion + /// 0.0 for the value. + public Version ClientAssemblyVersion => _clientAssemblyVersion ?? NoVersion; + + /// Gets the assembly version of the SDK runtime-contract assembly. + /// 0.0 for the value. + public Version SdkAssemblyVersion => _sdkAssemblyVersion ?? NoVersion; + + /// + /// Gets the informational version of the loaded CheatEngine.SDK.Engine assembly, for example + /// 2.0.0+<commit>, or when it declares none. + /// + public string? SdkPackageVersion { get; } - /// Gets the assembly version of the SDK runtime-contract assembly. - public Version SdkAssemblyVersion + /// + /// Gets whether the loaded CheatEngine.SDK is exactly the package this Client build consumed and was reviewed + /// with; another release of the supported major can still satisfy the package gate. + /// + public bool IsReviewedSdkPackage { get; } + + /// + /// Gets whether the observed Cheat Engine version has the major and minor components of the qualified baseline, + /// compared as integers; when no version was observed. + /// + public bool IsOnQualifiedCheatEngineLine => CheatEngineVersion is { } observed && + observed.Major == QualifiedCheatEngineBaseline.Major && + observed.Minor == QualifiedCheatEngineBaseline.Minor; + + /// Gets whether this value is the uninitialized . + internal bool IsDefault => _clientAssemblyVersion is null; } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs index 3d3ea11..3686507 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs @@ -22,7 +22,11 @@ public static ClientCapabilities Empty /// Gets the ordered observations as a read-only span. public ReadOnlySpan Entries => _entries; - /// + /// Tests this collection and another one for equal observations in the same order. + /// The collection to compare with. + /// + /// when holds equal observations in the same order. + /// public bool Equals(ClientCapabilities? other) { if (ReferenceEquals(this, other)) @@ -46,13 +50,23 @@ public bool Equals(ClientCapabilities? other) return true; } - /// + /// Tests this collection and an object for equal observations in the same order. + /// The object to compare with. + /// + /// when is a with equal + /// observations in the same order. + /// public override bool Equals(object? obj) { return obj is ClientCapabilities other && Equals(other); } /// Copies and validates a set of distinct Client capability observations. + /// The observations, one per capability, in the order to keep. + /// The collection, or when is empty. + /// + /// holds a observation or names a capability twice. + /// public static ClientCapabilities Create(ReadOnlySpan entries) { if (entries.IsEmpty) @@ -78,6 +92,11 @@ public static ClientCapabilities Create(ReadOnlySpanTries to get one explicit capability observation. + /// The identifier of the capability. + /// The observation when the collection holds one; otherwise the default value. + /// + /// when the collection holds an observation of . + /// public bool TryGet(ClientCapabilityId capability, out ClientCapabilityAvailability availability) { for (int index = 0; index < _entries.Length; index++) @@ -93,7 +112,8 @@ public bool TryGet(ClientCapabilityId capability, out ClientCapabilityAvailabili return false; } - /// + /// Returns a hash code consistent with the equality of the observations. + /// A hash code combined from every observation, in order. public override int GetHashCode() { HashCode hash = new(); diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs index fd65af2..2b02650 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs @@ -1,51 +1,32 @@ -using System.Runtime.InteropServices; - namespace CheatEngine.Client.Runtime; /// An immutable observation of a Client-owned high-level capability and its evidence. -[StructLayout(LayoutKind.Sequential)] public readonly record struct ClientCapabilityAvailability { - /// Creates an observation after validating its stable public shape. + private readonly string? _reason; + + /// Creates an availability projection from independently sourced prerequisite evidence. /// The stable Client-owned capability identifier. - /// The observed availability state. - /// The explicit probe, policy, or gate reason behind the state. - public ClientCapabilityAvailability( - ClientCapabilityId capability, - ClientCapabilityAvailabilityState state, - string reason) + /// The implementation, package, host, qualification, policy and lifetime gates. + /// + /// is empty, or is the uninitialized default value. + /// + public ClientCapabilityAvailability(ClientCapabilityId capability, ClientCapabilityEvidence evidence) { if (capability.IsEmpty) { throw new ArgumentException("A Client capability identifier is required.", nameof(capability)); } - if (!Enum.IsDefined(state)) + if (evidence.EffectiveReasonCode == ClientCapabilityEvidenceReasonCode.Unknown) { - throw new ArgumentOutOfRangeException(nameof(state), state, "The Client capability state is not defined."); + throw new ArgumentException("Initialized Client capability evidence is required.", nameof(evidence)); } - ArgumentException.ThrowIfNullOrWhiteSpace(reason); - - Capability = capability; - Evidence = CreateLegacyEvidence(state, reason); - State = Evidence.AvailabilityState; - Reason = Evidence.EffectiveReason; - } - - /// Creates an availability projection from independently sourced prerequisite evidence. - public ClientCapabilityAvailability(ClientCapabilityId capability, ClientCapabilityEvidence evidence) - { - if (capability.IsEmpty) - { - throw new ArgumentException("A Client capability identifier is required.", nameof(capability)); - } - - _ = evidence.EffectiveReason; Capability = capability; Evidence = evidence; State = evidence.AvailabilityState; - Reason = evidence.EffectiveReason; + _reason = evidence.EffectiveReason; } /// Gets the stable Client-owned capability identifier. @@ -67,26 +48,12 @@ public ClientCapabilityEvidence Evidence } /// Gets the explicit probe, policy, or gate reason behind the state. - public string Reason - { - get; - } + /// for the value. + public string Reason => _reason ?? string.Empty; /// Gets whether this capability was explicitly established as available. public bool IsAvailable => State == ClientCapabilityAvailabilityState.Available; /// Gets whether this capability was explicitly established as available or unavailable. public bool IsKnown => State != ClientCapabilityAvailabilityState.Unknown; - - private static ClientCapabilityEvidence CreateLegacyEvidence(ClientCapabilityAvailabilityState state, string reason) - { - ClientCapabilityEvidenceState evidenceState = state switch - { - ClientCapabilityAvailabilityState.Available => ClientCapabilityEvidenceState.Satisfied, - ClientCapabilityAvailabilityState.Unavailable => ClientCapabilityEvidenceState.Missing, - _ => ClientCapabilityEvidenceState.Unknown - }; - ClientCapabilityEvidenceGate gate = new(evidenceState, reason); - return new ClientCapabilityEvidence(gate, gate, gate, gate, gate, gate); - } } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs index 922c236..dfa2298 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs @@ -1,7 +1,7 @@ namespace CheatEngine.Client.Runtime; /// Describes the observed availability of a Client-owned high-level capability. -public enum ClientCapabilityAvailabilityState : byte +public enum ClientCapabilityAvailabilityState { /// The Client has not established every prerequisite for the capability. Unknown = 0, diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidence.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidence.cs index 661aa72..70a8520 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidence.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidence.cs @@ -7,6 +7,20 @@ namespace CheatEngine.Client.Runtime; public readonly record struct ClientCapabilityEvidence { /// Creates validated evidence for one Client capability. + /// Whether an operational Client adapter is delivered for the capability. + /// Whether the required primitive is established in the consumed package. + /// The observation of the required Cheat Engine host primitive. + /// Whether the required host profile passed its live qualification gate. + /// Whether Client policy permits the capability for the activation. + /// Whether the activation lifetime the capability requires is current. + /// + /// The state of a gate is not a defined value (, , + /// , , or + /// ). + /// + /// + /// A gate is the value, which has no reason. + /// public ClientCapabilityEvidence( ClientCapabilityEvidenceGate implementation, ClientCapabilityEvidenceGate package, @@ -66,7 +80,13 @@ public ClientCapabilityEvidenceGate Lifetime get; } - /// Gets the legacy availability projection without collapsing the underlying evidence dimensions. + /// + /// Gets the availability the gates establish: when a + /// gate is , + /// when every gate is + /// , otherwise + /// . + /// public ClientCapabilityAvailabilityState AvailabilityState { get @@ -82,14 +102,17 @@ public ClientCapabilityAvailabilityState AvailabilityState } } - /// Gets whether every prerequisite has been independently established. - public bool IsExecutable => AllSatisfied; - - /// Gets the reason for the highest-priority missing, faulted, malformed, or unknown prerequisite. - public string EffectiveReason => GetGate(EffectiveReasonCode).Reason; + /// + /// Gets the reason for the highest-priority missing, faulted, malformed, or unknown prerequisite; empty for the + /// uninitialized default value. + /// + public string EffectiveReason => EffectiveReasonCode == ClientCapabilityEvidenceReasonCode.Unknown + ? string.Empty + : GetGate(EffectiveReasonCode).Reason; /// - /// Gets the stable code for the evidence gate that supplies . Its state remains + /// Gets the stable code for the evidence gate that supplies , or + /// for the uninitialized default value. Its state remains /// available through the corresponding evidence-gate property. /// public ClientCapabilityEvidenceReasonCode EffectiveReasonCode => GetEffectiveReasonCode(); @@ -100,6 +123,12 @@ public ClientCapabilityAvailabilityState AvailabilityState private ClientCapabilityEvidenceReasonCode GetEffectiveReasonCode() { + // The constructor requires a reason for every gate, so a gate without one is the uninitialized default value. + if (Implementation.IsDefault) + { + return ClientCapabilityEvidenceReasonCode.Unknown; + } + if (Lifetime.State == ClientCapabilityEvidenceState.Missing) { return ClientCapabilityEvidenceReasonCode.Lifetime; @@ -206,7 +235,7 @@ private ClientCapabilityEvidenceGate GetGate(ClientCapabilityEvidenceReasonCode private bool HasState(ClientCapabilityEvidenceState state) { return Implementation.State == state || Package.State == state || Host.State == state || - LiveQualification.State == state || Policy.State == state || Lifetime.State == state; + LiveQualification.State == state || Policy.State == state || Lifetime.State == state; } private static void Validate(ClientCapabilityEvidenceGate gate, string parameterName) diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceGate.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceGate.cs index 3ee5775..2091afc 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceGate.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceGate.cs @@ -3,7 +3,14 @@ namespace CheatEngine.Client.Runtime; /// One immutable capability prerequisite together with its explicit observation reason. public readonly record struct ClientCapabilityEvidenceGate { + private readonly string? _reason; + /// Creates a validated capability prerequisite observation. + /// The observed prerequisite state. + /// The bounded reason for the observation. + /// is not a defined value. + /// is . + /// is empty or white space. public ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState state, string reason) { if (!Enum.IsDefined(state)) @@ -14,7 +21,7 @@ public ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState state, string ArgumentException.ThrowIfNullOrWhiteSpace(reason); State = state; - Reason = reason; + _reason = reason; } /// Gets the independently observed prerequisite state. @@ -24,11 +31,12 @@ public ClientCapabilityEvidenceState State } /// Gets the bounded reason for this prerequisite observation. - public string Reason - { - get; - } + /// for the value. + public string Reason => _reason ?? string.Empty; /// Gets whether the prerequisite has been established. public bool IsSatisfied => State == ClientCapabilityEvidenceState.Satisfied; + + /// Gets whether this value is the uninitialized , which has no reason. + internal bool IsDefault => _reason is null; } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceReasonCode.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceReasonCode.cs index 892c98e..c966cf5 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceReasonCode.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceReasonCode.cs @@ -1,23 +1,26 @@ namespace CheatEngine.Client.Runtime; /// Identifies the evidence gate that supplies . -public enum ClientCapabilityEvidenceReasonCode : byte +public enum ClientCapabilityEvidenceReasonCode { + /// No gate supplies a reason: the evidence is the uninitialized default value. + Unknown = 0, + /// The operational Client adapter gate supplies the effective reason. - Implementation = 0, + Implementation = 1, /// The consumed package artifact gate supplies the effective reason. - Package = 1, + Package = 2, /// The Cheat Engine host observation gate supplies the effective reason. - Host = 2, + Host = 3, /// The live host-qualification gate supplies the effective reason. - LiveQualification = 3, + LiveQualification = 4, /// The Client policy gate supplies the effective reason. - Policy = 4, + Policy = 5, /// The activation-lifetime gate supplies the effective reason. - Lifetime = 5 + Lifetime = 6 } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceState.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceState.cs index 77d45a6..f2d9171 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceState.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityEvidenceState.cs @@ -1,7 +1,7 @@ namespace CheatEngine.Client.Runtime; /// Describes one independently established prerequisite in a Client capability evidence record. -public enum ClientCapabilityEvidenceState : byte +public enum ClientCapabilityEvidenceState { /// The Client has not established this prerequisite for the current observation. Unknown = 0, diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs index 1c7ec58..a10c63d 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs @@ -6,6 +6,9 @@ namespace CheatEngine.Client.Runtime; private readonly string? _value; /// Creates a non-empty Client capability identifier. + /// The identifier, compared ordinally; the Client's own start with Client.. + /// is . + /// is empty or white space. public ClientCapabilityId(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); @@ -45,61 +48,70 @@ public ClientCapabilityId(string value) /// Gets the Client capability for owned target-memory allocations. public static ClientCapabilityId Allocations => new("Client.Allocations"); - /// Gets the Client capability for assembly, disassembly, comments, and Auto Assembler patches. + /// Gets the Client capability for instruction assembly and disassembly. + /// + /// The identifier is stable, but the instruction client it describes (ICheatEngineClient.Assembly) is + /// experimental (CECLIENT5003). + /// public static ClientCapabilityId Assembly => new("Client.Assembly"); - /// Gets the Client capability for bounded remote execution and DLL injection. - public static ClientCapabilityId RemoteExecution => new("Client.RemoteExecution"); - - /// Gets the Client capability for debugger breakpoints and copied debugger events. - public static ClientCapabilityId Debugger => new("Client.Debugger"); - - /// Gets the Client capability for activation-scoped hotkeys. - public static ClientCapabilityId Hotkeys => new("Client.Hotkeys"); - - /// Gets the Client capability for activation-scoped timers. - public static ClientCapabilityId Timers => new("Client.Timers"); - - /// Gets the Client capability for observing and changing target speed. - public static ClientCapabilityId Speed => new("Client.Speed"); - - /// Gets the Client capability for target-memory and file hashing. - public static ClientCapabilityId Hashing => new("Client.Hashing"); - - /// Gets the Client capability for explicitly initialized DBVM operations and watches. - public static ClientCapabilityId Dbvm => new("Client.Dbvm"); - - /// + /// Gets the Client capability for explicitly opted-in Auto Assembler patches. + /// + /// The identifier is stable, but the Auto Assembler client it describes is experimental (CECLIENT5004) and + /// is registered only when the activation calls EnableAutoAssemblerPatches(). + /// + public static ClientCapabilityId AutoAssemblerPatches => new("Client.AutoAssemblerPatches"); + + /// Tests this identifier and another one for ordinal equality. + /// The identifier to compare with. + /// + /// when both identifiers have the same value; two default values are equal. + /// public bool Equals(ClientCapabilityId other) { return string.Equals(_value, other._value, StringComparison.Ordinal); } - /// + /// Tests this identifier and an object for ordinal equality. + /// The object to compare with. + /// + /// when is a with the same + /// value. + /// public override bool Equals(object? obj) { return obj is ClientCapabilityId other && Equals(other); } - /// + /// Returns a hash code consistent with the ordinal equality of the value. + /// The ordinal hash code of the value, or zero for the identifier. public override int GetHashCode() { return _value is null ? 0 : StringComparer.Ordinal.GetHashCode(_value); } - /// + /// Returns the identifier value. + /// + /// : the identifier, or an empty string for the value. + /// public override string ToString() { return Value; } /// Tests two identifiers for ordinal equality. + /// The first identifier. + /// The second identifier. + /// when both identifiers have the same value. public static bool operator ==(ClientCapabilityId left, ClientCapabilityId right) { return left.Equals(right); } /// Tests two identifiers for ordinal inequality. + /// The first identifier. + /// The second identifier. + /// when the identifiers have different values. public static bool operator !=(ClientCapabilityId left, ClientCapabilityId right) { return !left.Equals(right); diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs index 29451d8..eb3a63c 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs @@ -1,9 +1,22 @@ using CheatEngine.Client.Results; -using CheatEngine.SDK.Engine.Runtime; namespace CheatEngine.Client.Runtime; /// Reads immutable runtime facts and capability observations for the active Cheat Engine activation. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// After its arguments, every operation checks the activation: an ended activation throws +/// and a stopping one +/// , except that a deactivation callback can still call every +/// operation on Cheat Engine's main thread (see ). A Try member +/// returns every other failure; the throwing member with the same inputs throws it through +/// . +/// +/// public interface ICheatEngineRuntime { /// Gets the activation epoch for which this runtime service is valid. @@ -13,27 +26,52 @@ public long Epoch } /// Tries to capture the runtime facts available to the active plugin. + /// The captured runtime facts on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the capture is dispatched to Cheat Engine's main thread. + /// when the runtime facts were captured. + /// + /// A fact that Cheat Engine could not report stays unknown in the snapshot instead of failing the call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetSnapshot( out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Captures the runtime facts or throws when they are unavailable. + /// Observed before the capture is dispatched to Cheat Engine's main thread. + /// The captured runtime facts. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the capture failed with + /// . + /// + /// + /// The capture observed the cancellation of . + /// + /// The capture failed with any other failure kind. public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToken = default); - /// Reads one SDK capability observation without exposing its backing Lua global. - public bool TryGetSdkCapability( - RuntimeCapabilityId capability, - out RuntimeCapabilityAvailability availability, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Reads one SDK capability observation or throws when it cannot be observed. - public RuntimeCapabilityAvailability GetSdkCapability( - RuntimeCapabilityId capability, - CancellationToken cancellationToken = default); - /// Reads one Client capability observation without exposing internal adapters or handles. + /// The identifier of the capability to read. + /// + /// The observation on success; a capability this Client release does not define is reported with unknown + /// evidence. Otherwise the default value. + /// + /// The classified failure; the default value on success. + /// Observed before the capture is dispatched to Cheat Engine's main thread. + /// when the runtime facts behind the observation were captured. + /// + /// is the identifier, which names no capability. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetClientCapability( ClientCapabilityId capability, out ClientCapabilityAvailability availability, @@ -41,6 +79,21 @@ public bool TryGetClientCapability( CancellationToken cancellationToken = default); /// Reads one Client capability observation or throws when it cannot be observed. + /// The identifier of the capability to read. + /// Observed before the capture is dispatched to Cheat Engine's main thread. + /// The observation; a capability this Client release does not define has unknown evidence. + /// + /// is the identifier, which names no capability. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the capture failed with + /// . + /// + /// + /// The capture observed the cancellation of . + /// + /// The capture failed with any other failure kind. public ClientCapabilityAvailability GetClientCapability( ClientCapabilityId capability, CancellationToken cancellationToken = default); diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs index c8ac7c8..bea9ab4 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs @@ -18,6 +18,8 @@ namespace CheatEngine.Client.Scanning; /// public readonly record struct AobPattern { + private readonly string? _value; + /// Creates and normalizes an AOB pattern. /// Hexadecimal byte tokens and ?? wildcard tokens. /// is . @@ -31,21 +33,19 @@ public AobPattern(string value) "An AOB pattern must contain only two-digit hexadecimal bytes or ?? wildcard tokens.", nameof(value)); } - Value = normalized; + _value = normalized; ByteLength = byteLength; } private AobPattern(string normalized, int byteLength) { - Value = normalized; + _value = normalized; ByteLength = byteLength; } /// Gets the normalized Cheat Engine pattern text. - public string Value - { - get; - } + /// for the value. + public string Value => _value ?? string.Empty; /// Gets the number of byte positions represented by the pattern. public int ByteLength @@ -92,9 +92,12 @@ public static bool TryParse(string? value, out AobPattern pattern) } /// Formats the pattern as normalized Cheat Engine text. + /// + /// : the normalized text, or an empty string for the value. + /// public override string ToString() { - return Value ?? string.Empty; + return Value; } private static bool TryNormalize(ReadOnlySpan value, out string normalized, out int byteLength) @@ -168,7 +171,7 @@ private static void AppendToken(StringBuilder builder, char high, char low) private static bool TryGetHexDigit(char value, out char normalized) { - if (value is >= '0' and <= '9' or >= 'A' and <= 'F') + if (value is (>= '0' and <= '9') or (>= 'A' and <= 'F')) { normalized = value; return true; diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs deleted file mode 100644 index 0364356..0000000 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Globalization; - -using CheatEngine.SDK.Engine.Enums; -using CheatEngine.SDK.Engine.Scanning.Aob; - -namespace CheatEngine.Client.Scanning; - -/// Validates the documented CE AOB protection and alignment text before an SDK call. -internal static class AobScanOptionsNormalizer -{ - internal static AobScanOptions Normalize(AobScanOptions options) - { - string? protection = NormalizeProtection(options.ProtectionFlags); - string? alignment = NormalizeAlignmentParameter(options.AlignmentMethod, options.AlignmentParameter); - return new AobScanOptions(protection, options.AlignmentMethod, alignment); - } - - private static string? NormalizeProtection(string? value) - { - if (value is null || value.Length == 0) - { - return value; - } - - Span modes = stackalloc char[3]; - int seen = 0; - for (int index = 0; index < value.Length;) - { - AddProtectionClause(value, ref index, modes, ref seen); - } - - return FormatProtection(modes, seen); - } - - private static void AddProtectionClause(string value, ref int index, Span modes, ref int seen) - { - if (index + 1 >= value.Length) - { - throw new ArgumentException( - "An AOB protection expression consists of +, -, or * followed by X, C, or W.", nameof(value)); - } - - char mode = value[index++]; - char flag = value[index++]; - if (mode is not ('+' or '-' or '*')) - { - throw new ArgumentException( - "An AOB protection expression consists of unique +, -, or * X/C/W clauses.", nameof(value)); - } - - int slot = GetProtectionSlot(flag); - if (slot < 0 || IsProtectionSlotPresent(seen, slot)) - { - throw new ArgumentException( - "An AOB protection expression consists of unique +, -, or * X/C/W clauses.", nameof(value)); - } - - modes[slot] = mode; - seen |= 1 << slot; - } - - private static string FormatProtection(ReadOnlySpan modes, int seen) - { - Span normalized = stackalloc char[6]; - int written = 0; - for (int slot = 0; slot < modes.Length; slot++) - { - if (!IsProtectionSlotPresent(seen, slot)) - { - continue; - } - - normalized[written++] = modes[slot]; - normalized[written++] = GetProtectionFlag(slot); - } - - return new string(normalized[..written]); - } - - private static int GetProtectionSlot(char flag) - { - return flag switch - { - 'X' or 'x' => 0, - 'C' or 'c' => 1, - 'W' or 'w' => 2, - _ => -1 - }; - } - - private static char GetProtectionFlag(int slot) - { - return slot switch { 0 => 'X', 1 => 'C', _ => 'W' }; - } - - private static bool IsProtectionSlotPresent(int seen, int slot) - { - return (seen & (1 << slot)) != 0; - } - - private static string? NormalizeAlignmentParameter(FastScanMethod method, string? value) - { - return method switch - { - FastScanMethod.NotAligned => value, - FastScanMethod.Aligned => NormalizeDivisor(value), - FastScanMethod.LastDigits => NormalizeHexSuffix(value), - _ => throw new ArgumentOutOfRangeException(nameof(method), method, - "AOB alignment must use a documented Cheat Engine fast-scan method.") - }; - } - - private static string NormalizeDivisor(string? value) - { - if (string.IsNullOrEmpty(value) || - !ulong.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out ulong divisor) || divisor == 0) - { - throw new ArgumentException( - "An aligned AOB scan requires a positive decimal alignment divisor.", nameof(value)); - } - - return divisor.ToString(CultureInfo.InvariantCulture); - } - - private static string NormalizeHexSuffix(string? value) - { - if (string.IsNullOrEmpty(value) || value.Length > sizeof(ulong) * 2) - { - throw new ArgumentException( - "A last-digits AOB scan requires one to sixteen hexadecimal digits.", nameof(value)); - } - - Span normalized = stackalloc char[value.Length]; - for (int index = 0; index < value.Length; index++) - { - char character = value[index]; - if (character is >= '0' and <= '9' or >= 'A' and <= 'F') - { - normalized[index] = character; - continue; - } - - if (character is >= 'a' and <= 'f') - { - normalized[index] = (char) (character - ('a' - 'A')); - continue; - } - - throw new ArgumentException( - "A last-digits AOB scan requires one to sixteen hexadecimal digits.", nameof(value)); - } - - return new string(normalized); - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs index 4eb843a..546c174 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs @@ -2,11 +2,14 @@ namespace CheatEngine.Client.Scanning; -/// An inclusive target-address range used to filter copied AOB match addresses. +/// An inclusive range of AOB match start addresses. /// -/// String-form AOBScan does not accept start and stop address arguments. The global scan is therefore not -/// narrowed by this range: Core applies it while copying each matching address from the SDK-owned result list and -/// before it contributes to the caller's materialization limit. +/// is the last allowed match start, on every route. On the bounded route Cheat Engine scans only +/// [Start, End + pattern length), so a match starting at is found when it also fits entirely +/// inside the requested module, if any, and ends below the top of the 64-bit address space (the stop bound +/// saturates there). When the bounded route cannot run, the global AOBScan is not narrowed: Core applies the +/// same rule while copying each matching address, before it contributes to the caller's materialization limit, and +/// the range does not reduce Cheat Engine's scan time or memory. /// public readonly record struct AobScanRange { @@ -39,6 +42,11 @@ public Address End } /// Gets whether is inside this inclusive range. + /// The address to test. + /// + /// when is at or after and at or + /// before . + /// public bool Contains(Address address) { return address >= Start && address <= End; diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs index 35ec6eb..c36ceca 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs @@ -1,14 +1,23 @@ using CheatEngine.SDK.Engine.Inspection; -using CheatEngine.SDK.Engine.Scanning.Aob; namespace CheatEngine.Client.Scanning; -/// An immutable AOB scan request with an explicit managed, post-filter materialization limit. +/// An immutable AOB scan request with an explicit materialization limit and an optional module and range scope. public readonly record struct AobScanRequest { /// Creates an AOB scan request. - public AobScanRequest(AobPattern pattern, AobScanOptions options, int maximumResults, ModuleName? module = null, - AobScanRange? range = null) + /// The normalized pattern. + /// The positive materialization limit. + /// The optional module that scopes the scan. + /// The optional inclusive range of match start addresses that scopes the scan. + /// The memory protection the matches must have; unspecified by default. + /// The alignment rule of candidate addresses; by default. + /// + /// is empty, or is an empty module name. + /// + /// is zero or negative. + public AobScanRequest(AobPattern pattern, int maximumResults, ModuleName? module = null, AobScanRange? range = null, + ScanProtectionFilter protection = default, ScanAlignment alignment = default) { if (string.IsNullOrWhiteSpace(pattern.Value)) { @@ -22,10 +31,11 @@ public AobScanRequest(AobPattern pattern, AobScanOptions options, int maximumRes } Pattern = pattern; - Options = AobScanOptionsNormalizer.Normalize(options); MaximumResults = maximumResults; Module = module; Range = range; + Protection = protection; + Alignment = alignment; } /// Gets the scan pattern. @@ -34,28 +44,55 @@ public AobPattern Pattern get; } - /// Gets the SDK's evidence-backed optional scan arguments. - public AobScanOptions Options - { - get; - } - /// Gets the maximum number of copied addresses that may survive managed post-filters. - /// This bounds result materialization only; it does not bound or terminate the global Cheat Engine scan. + /// + /// This is the materialization limit: it bounds how many addresses Core copies from Cheat Engine's result. It does + /// not bound or terminate the Cheat Engine scan, and it is not the number of available results (see + /// ). Every route copies at most 65,535 addresses, whatever this + /// limit; a result cut by that cap is truncated (). + /// public int MaximumResults { get; } - /// Gets the optional module Core resolves before the global scan and applies as a copied-address post-filter. + /// Gets the optional module that scopes the scan; Core resolves it before any scan. + /// + /// A match is kept only when all of its pattern bytes lie inside the module, on every route: a match that + /// straddles the module end is never reported. On a qualified local target Cheat Engine scans only the module + /// (intersected with , ); otherwise Cheat + /// Engine scans the whole target and Core applies the same rule while copying + /// (). + /// public ModuleName? Module { get; } - /// Gets the optional inclusive copied-address post-filter. + /// Gets the optional inclusive range of match start addresses that scopes the scan. + /// + /// A match is kept when its start lies in the range, on every route (and, with , when it also + /// fits entirely inside the module). On a qualified local target Cheat Engine scans only + /// [Start, End + pattern length), intersected with + /// (); otherwise the range is applied while copying and does not + /// reduce Cheat Engine's scan time or memory (). + /// public AobScanRange? Range { get; } + + /// Gets the memory protection the matches must have. + /// Cheat Engine applies it on every route: it reduces the memory Cheat Engine scans. + public ScanProtectionFilter Protection + { + get; + } + + /// Gets the alignment rule of candidate addresses. + /// Cheat Engine applies it on every route. + public ScanAlignment Alignment + { + get; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs index 09826fc..e8f3764 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs @@ -5,13 +5,20 @@ namespace CheatEngine.Client.Scanning; /// A copied, handle-free AOB scan result. -public readonly record struct AobScanResult +public readonly struct AobScanResult { + private readonly ImmutableArray
_matches; + /// Creates a copied AOB scan result. + /// The copied match addresses; a default array is empty. + /// Whether the copy is not proven complete (see ). + /// + /// is and is empty. + /// public AobScanResult(ImmutableArray
matches, bool isTruncated) { - Matches = matches.IsDefault ? ImmutableArray
.Empty : matches; - if (isTruncated && Matches.IsEmpty) + _matches = matches.IsDefault ? ImmutableArray
.Empty : matches; + if (isTruncated && _matches.IsEmpty) { throw new ArgumentException("A truncated AOB result must retain at least one copied match.", nameof(matches)); @@ -21,12 +28,32 @@ public AobScanResult(ImmutableArray
matches, bool isTruncated) } /// Gets the materialized target addresses. - public ImmutableArray
Matches - { - get; - } + /// Empty for the value, never a default array. + public ImmutableArray
Matches => _matches.IsDefault ? ImmutableArray
.Empty : _matches; - /// Gets whether additional CE matches were omitted because the request limit was reached. + /// + /// Gets whether the copy is not proven complete: more matches inside the request may exist beyond the + /// copied ones, or rows Cheat Engine returned were left unread. + /// + /// + /// + /// Every route sets it when it found one more match inside the request than it copied: the copy + /// stopped at or at the Client's cap of 65,535 + /// addresses, whichever is lower, and further matches exist. + /// + /// + /// The bounded route () also sets it when its + /// destination filled up with rows outside the request, for example matches that straddle the module + /// end, while Cheat Engine returned more rows: those rows were not read + /// (), so whether they hold further matches is + /// unknown. The global route reads every row of the same result, so it can report the same matches as + /// complete. + /// + /// + /// means that every match inside the request was copied. Never read a + /// truncated result as a count, and never read its lack of a second match as uniqueness. + /// + /// public bool IsTruncated { get; diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs b/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs index d628f4f..95e4012 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs @@ -2,13 +2,165 @@ namespace CheatEngine.Client.Scanning; -/// Runs global AOB scans, then copies post-filtered addresses before releasing SDK-owned objects. +/// Runs AOB scans on the cheapest route the request allows and copies their addresses before releasing SDK-owned objects. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Routes: what they answer and what they cost. Core picks one of three routes for each request; +/// names the one that ran and +/// says why: +/// +/// +/// +/// Route +/// Answer and cost +/// +/// +/// +/// +/// A request without or +/// (). Cheat Engine runs one global AOBScan +/// over the whole target: the cost is a full scan whatever the materialization limit. Matches are exact, +/// but zero matches are indeterminate (). +/// +/// +/// +/// +/// +/// A module and/or range request on a target that Cheat Engine reports as a qualified local process +/// (). Cheat Engine runs an exhaustive +/// MemScan limited to the module intersected with the range, so its cost is proportional to those bounds, +/// and zero matches are a factual empty result when Cheat Engine's error text was readable. The call +/// blocks Cheat Engine's main thread for the scan, the copy and the release, and a started scan cannot be +/// interrupted in 1.0: cancellation is observed only between Cheat Engine calls. +/// +/// +/// +/// +/// +/// A module and/or range request whose bounded route cannot run +/// (): an unqualified target such as a +/// CEServer or file-as-process selection, or a scan session CheatEngine.SDK could not create or attach to +/// one target. Cheat Engine runs the global scan, at the cost of a full scan (after the bounded scan, when +/// that scan had run before the SDK lost the target's identity), and Core applies the module and range +/// while copying. A global result list with no match inside the request is a successful empty result; a +/// global scan that returns no list is indeterminate as on the global route. +/// is always on this +/// route. +/// +/// +/// +/// +/// One scope rule on every route. With , a match is kept only when all +/// of its pattern bytes lie inside [BaseAddress, BaseAddress + ImageSize): a match that straddles the module +/// end is never reported. With , a match is kept when its start lies in +/// [Start, End]. Both rules apply together, so the same request returns the same addresses whichever route +/// ran. Core resolves the module before any scan, and a request whose module and range leave no room for one whole +/// match (a range that ends before the module can hold one, or a module smaller than the pattern) is refused before +/// any scan. +/// +/// +/// bounds only how many addresses Core copies; it never stops Cheat +/// Engine early, and every route copies at most 65,535 addresses. The copied order is Cheat Engine's result-list +/// order, which Cheat Engine does not specify. +/// +/// +/// Four scan limits are distinct: the Cheat Engine work limit (the bounds on the bounded route, none on the global +/// routes), the available results (), the materialization limit +/// (), and the call deadline (none: a cancellation token is observed +/// only between Cheat Engine calls and Client-managed steps, and cannot interrupt a scan that Cheat Engine has +/// started). reports the counts and the Cheat Engine scan time separately from the +/// Client copy time. +/// +/// +/// On a global route a scan for which Cheat Engine returns no result list is reported as +/// : on Cheat Engine 7.7 AOBScan returns +/// nil for zero matches, and a host failure can return the same shape, so that route cannot tell them +/// apart. It is never reported as . The other host outcomes keep +/// their own kinds: an absent AOBScan is , a +/// raising call is , a malformed result is +/// , and an empty list that Cheat Engine does return is a +/// successful no-match. When Cheat Engine's selected target changes during the scan, its addresses are discarded +/// and the scan fails with or +/// . +/// +/// public interface IPatternScanner { - /// Tries to run one scan with managed post-filtered result materialization. + /// Tries to run one scan and copy its matches. + /// The scan request. + /// The copied matches on success; otherwise the default value. + /// The classified failure; the default value on success. + /// + /// Observed before dispatch, between Cheat Engine calls and between Client-managed steps; it never interrupts a + /// Cheat Engine scan that has already started (see ). + /// + /// when the scan ran and its matches were copied. + /// + /// Expected failures, including an unconfirmed release of the Cheat Engine result list or scan session + /// (), are returned as . Lifecycle + /// faults throw or + /// . + /// + /// + /// is the request, or a tampered one: an + /// for a limit, a range or an option its constructor would refuse. + /// It is thrown before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Runs one scan with managed post-filtered result materialization or throws when it fails. + /// Runs one scan and copies its matches, or throws when it fails. + /// The scan request. + /// + /// Observed before dispatch, between Cheat Engine calls and between Client-managed steps; it never interrupts a + /// Cheat Engine scan that has already started (see ). + /// + /// The copied matches. + /// + /// is the request, or a tampered one: an + /// for a limit, a range or an option its constructor would refuse. + /// It is thrown before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the scan failed with + /// . + /// + /// + /// The scan observed the cancellation of . + /// + /// + /// The scan failed with any other failure kind, + /// for a global scan without a result list included. + /// public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default); + + /// Runs one scan and returns its detailed outcome. + /// The scan request. + /// + /// Observed before dispatch, between Cheat Engine calls and between Client-managed steps; it never interrupts a + /// Cheat Engine scan that has already started (see ). + /// + /// + /// The detailed outcome: the result or failure would return, the metrics, the host's own + /// outcome, the route reason and whether the target identity was verified. Expected failures are returned in + /// . + /// + /// + /// is the request, or a tampered one, as + /// throws. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public PatternScanOutcome ScanDetailed(AobScanRequest request, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs index e7f60ae..6d92292 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs @@ -1,54 +1,274 @@ +using System.Diagnostics.CodeAnalysis; + using CheatEngine.Client.Results; -using CheatEngine.SDK.Engine.Scanning.Values; namespace CheatEngine.Client.Scanning; -/// A main-thread-bound, explicitly disposable high-level value-scan session. +/// One value scan over Cheat Engine's scanner: a first scan, next scans, and bounded reads of the results. /// -/// The session exposes a Client-managed rather than the SDK's raw scan state. Its -/// operations complete Cheat Engine's required wait-and-initialize sequence before publishing copied results, and -/// never -/// expose MemScan, FoundList, Lua, or ownership wrappers. +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5001). The value-scan API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// Sequence. A session accepts a first scan in , then next +/// scans, counts and reads in ; returns +/// it to . An operation that the state does not accept is refused with +/// and . Every +/// operation runs on Cheat Engine's main thread through the activation dispatcher; a first or next scan starts +/// Cheat Engine's scan and waits for it in the same call, so Cheat Engine's main thread is busy until the scan +/// ends. +/// +/// +/// Re-entrancy. Cheat Engine runs queued main-thread work while it waits for a scan. A call to this session +/// made from such work is refused with ; a release requested +/// from it runs once, when the scan call has returned. +/// +/// +/// Cancellation. The token is observed before each Cheat Engine call and never interrupts one. A scan +/// cancelled after Cheat Engine started it and before the Client waits for it reports +/// with : the session +/// stays and accepts only its release, which asks Cheat Engine to +/// stop the scan and waits for it for up to five seconds. A cancellation observed after Cheat Engine finished +/// reports and publishes nothing. +/// +/// +/// Failures. When a scan fails after Cheat Engine began it, the failure message ends with Cheat Engine's own +/// error text, bounded to 1024 bytes, when it reported one; classify the failure by its kind, never by that text. +/// An operation after the session's target or Lua runtime changed is refused with +/// , +/// or ; only the release remains. After the release, an +/// operation fails with , unless such a change refused the +/// release (, +/// or +/// ): it then keeps failing with the kind of that change, +/// which a throwing form throws as . +/// +/// +/// Release. destroys the found list, then the scanner, on Cheat +/// Engine's main thread, and reports the worse of the two outcomes; a scan that may still run is asked to stop +/// first, and a stop that Cheat Engine did not confirm is . +/// CheatEngine.SDK never destroys them through another target: after a target change it refuses the release +/// before any Cheat Engine call ( or +/// ), which requires manual recovery. After a +/// change of the Lua runtime, or when the target was never checked, CheatEngine.SDK consumes the session without +/// any Cheat Engine call and reports a release that could not begin: the outcome is +/// , stays +/// while is , and the +/// lease stays registered so that the deactivation report carries it. Retrying that release destroys nothing, +/// since CheatEngine.SDK no longer owns the objects. +/// /// -public interface IValueScanSession : IDisposable +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public interface IValueScanSession : ICheatEngineLease { - /// Gets the session's current conservative scan state. + /// Gets the target-selection epoch of the process the session was created in. + public long SelectionEpoch + { + get; + } + + /// Gets the session state that Cheat Engine's scan session reported after the last operation. public ValueScanSessionState State { get; } - /// Runs a first scan and prepares its results for reading. - public bool TryStart(FirstScanRequest request, out CheatEngineFailure failure, + /// + /// Gets why the session is , or + /// . + /// + public ValueScanInvalidationKind Invalidation + { + get; + } + + /// Tries to run a first scan and wait for its results. + /// The first scan. + /// The classified failure when the method returns . + /// Observed before the scan starts, before the wait, and after it. + /// when the results are ready. + /// + /// did not come from a factory: the + /// request, or a tampered one (an for a + /// comparison, a value type, a range or an option its factories would refuse). It is thrown before the + /// activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryFirstScan(ValueScanFirstRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Runs a first scan or throws when it fails. - public void Start(FirstScanRequest request, CancellationToken cancellationToken = default); + /// Runs a first scan and waits for its results, or throws the failure. + /// The first scan. + /// Observed before the scan starts, before the wait, and after it. + /// + /// did not come from a factory: the + /// request, or a tampered one (an for a + /// comparison, a value type, a range or an option its factories would refuse). It is thrown before the + /// activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the scan failed with + /// : a state the session does not accept, a re-entrant call, + /// or a released session, unless a target or Lua runtime change refused its release (see the remarks). + /// + /// + /// The scan observed the cancellation of . + /// + /// + /// The scan failed with any other failure kind. + /// + public void FirstScan(ValueScanFirstRequest request, CancellationToken cancellationToken = default); - /// Runs a next scan and prepares its results for reading. - public bool TryRunNextScan(NextScanRequest request, out CheatEngineFailure failure, + /// Tries to run a next scan over the current results and wait for its results. + /// The next scan. + /// The classified failure when the method returns . + /// Observed before the scan starts, before the wait, and after it. + /// when the new results are ready. + /// + /// did not come from a factory: the + /// request, or a tampered one (an for a + /// comparison or a value type that is not a defined value). It is thrown before the activation check and + /// before any Cheat Engine call; a value of another type than the session's first scan is refused with + /// instead. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryNextScan(ValueScanNextRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Runs a next scan or throws when it fails. - public void RunNextScan(NextScanRequest request, CancellationToken cancellationToken = default); + /// Runs a next scan over the current results and waits for its results, or throws the failure. + /// The next scan. + /// Observed before the scan starts, before the wait, and after it. + /// + /// did not come from a factory: the + /// request, or a tampered one (an for a + /// comparison or a value type that is not a defined value). It is thrown before the activation check and + /// before any Cheat Engine call; a value of another type than the session's first scan is refused with + /// instead. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the scan failed with + /// : a state the session does not accept, a re-entrant call, + /// or a released session, unless a target or Lua runtime change refused its release (see the remarks). + /// + /// + /// The scan observed the cancellation of . + /// + /// + /// The scan failed with any other failure kind. + /// + public void NextScan(ValueScanNextRequest request, CancellationToken cancellationToken = default); - /// Resets the scan session to its initial state. + /// Tries to clear the results so that the session accepts a new first scan. + /// The classified failure when the method returns . + /// Observed before Cheat Engine clears the results, and after. + /// when the session is . + /// + /// A reset recovers an session while its target and Lua runtime + /// are unchanged; it is refused while Cheat Engine may still be scanning. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryReset(out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Resets the scan session or throws when it fails. + /// Clears the results so that the session accepts a new first scan, or throws the failure. + /// Observed before Cheat Engine clears the results, and after. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the reset failed with + /// : a state the session does not accept, a re-entrant call, + /// or a released session, unless a target or Lua runtime change refused its release (see the remarks). + /// + /// + /// The reset observed the cancellation of . + /// + /// + /// The reset failed with any other failure kind. + /// public void Reset(CancellationToken cancellationToken = default); - /// Gets the current result count after results are ready. + /// Tries to read the number of current results. + /// The number of results when the method returns . + /// The classified failure when the method returns . + /// Observed before Cheat Engine is asked, and after. + /// when the count was read. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetResultCount(out ulong resultCount, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Gets the current result count or throws when results are not ready. + /// Reads the number of current results, or throws the failure. + /// Observed before Cheat Engine is asked, and after. + /// The number of results. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the count failed with + /// : a state the session does not accept, a re-entrant call, + /// or a released session, unless a target or Lua runtime change refused its release (see the remarks). + /// + /// + /// The count observed the cancellation of . + /// + /// + /// The count failed with any other failure kind. + /// public ulong GetResultCount(CancellationToken cancellationToken = default); - /// Copies a bounded page of current results after results are ready. + /// Tries to copy one bounded page of the current results. + /// The first index and the maximum number of results to copy. + /// The copied page when the method returns . + /// The classified failure when the method returns . + /// Observed before the copy and between the copied results. + /// + /// when the page was copied; a scan without results reads as an empty page. A start index + /// at or beyond a non-zero result count is refused with . + /// + /// + /// is the request, which allows no result, or a tampered + /// one: it is thrown before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryRead(ValueScanReadRequest request, out ValueScanPage page, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Copies a bounded page or throws when the session is not ready. + /// Copies one bounded page of the current results, or throws the failure. + /// The first index and the maximum number of results to copy. + /// Observed before the copy and between the copied results. + /// The copied page. + /// + /// is the request, which allows no result, or a tampered + /// one: it is thrown before the activation check and before any Cheat Engine call. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// : a state the session does not accept, a re-entrant call, + /// or a released session, unless a target or Lua runtime change refused its release (see the remarks). + /// + /// + /// The read observed the cancellation of . + /// + /// + /// The read failed with any other failure kind. + /// public ValueScanPage Read(ValueScanReadRequest request, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs index 0960dd0..b57062b 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs @@ -1,14 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + using CheatEngine.Client.Results; namespace CheatEngine.Client.Scanning; -/// Creates explicitly owned value-scan sessions. +/// Creates value-scan sessions over Cheat Engine's own scanner, one session per independent scan. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add members +/// to it, so implement it only in a test double. +/// +/// +/// Experimental (CECLIENT5001). The value-scan API can change in a minor release until its live +/// scenarios pass; see the Abstractions README. +/// +/// +/// A session owns one Cheat Engine MemScan and its FoundList, created for the target Cheat Engine +/// has selected. Creation is refused with when the +/// identity of that target cannot be established, before any Cheat Engine object exists. The session belongs to +/// the activation and to that target: disabling the plugin releases it, and selecting another process ends it +/// with a release that CheatEngine.SDK refuses on the new target, which leaves both objects in Cheat Engine +/// (see ). Release a session before selecting another process. +/// +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public interface IValueScanner { - /// Tries to create a value-scan session for the current activation. - public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + /// Tries to create a value-scan session for Cheat Engine's selected target. + /// The new session when the method returns ; release it when done. + /// The classified failure when the method returns . + /// Observed before Cheat Engine creates the session, and after. + /// when the session was created. + /// + /// A cancellation observed after Cheat Engine created the session releases it at once and publishes nothing. + /// + /// The activation has ended. + /// + /// The activation is stopping: no new lease is created while it stops. + /// + public bool TryCreateSession([NotNullWhen(true)] out IValueScanSession? session, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Creates a value-scan session or throws when the host capability is unavailable. + /// Creates a value-scan session for Cheat Engine's selected target, or throws the failure. + /// Observed before Cheat Engine creates the session, and after. + /// The new session; release it when done. + /// The activation has ended. + /// + /// The activation is stopping, or the creation failed with . + /// + /// + /// The creation observed the cancellation of . + /// + /// The creation failed with any other failure kind. public IValueScanSession CreateSession(CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanHostOutcomeKind.cs b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanHostOutcomeKind.cs new file mode 100644 index 0000000..70c5124 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanHostOutcomeKind.cs @@ -0,0 +1,71 @@ +namespace CheatEngine.Client.Scanning; + +/// What Cheat Engine, through CheatEngine.SDK, reported for the scan that ran. +/// +/// +/// The value is the host's own outcome, before the Client decides the result: a global scan that reported +/// can still fail, for example when the selected target changed during the call or when the +/// result list release was not confirmed; carries that verdict. For a +/// request that fell back from the bounded route, the value is the global scan's outcome. +/// +/// +/// The global AOBScan route reports , , +/// , , , +/// or . The bounded route reports +/// , , , , +/// , , , +/// or : a fact that both routes report has one +/// member. means no host outcome was observed (the request was refused or failed before +/// a scan) or the outcome is not one this Client knows. +/// +/// +public enum PatternScanHostOutcomeKind +{ + /// No host outcome was observed, or it is not one this Client version knows. + Unknown = 0, + + /// Cheat Engine returned at least one match. + Matches = 1, + + /// + /// Cheat Engine returned no match: a valid empty list on the global route (not observed on Cheat Engine 7.7), or + /// no in-bounds row on the bounded route. + /// + NoMatches = 2, + + /// + /// The global AOBScan returned nil: on Cheat Engine 7.7 zero matches and host failures share this + /// shape. + /// + NoResult = 3, + + /// The AOBScan global was absent or not callable. + GlobalUnavailable = 4, + + /// + /// A protected Lua call of the scan failed: the global AOBScan lookup or call, or a call of the bounded + /// scan (its scan, wait, count or row reads). + /// + ProtectedLuaFailure = 5, + + /// Cheat Engine returned a malformed value, count or row. + InvalidResult = 6, + + /// The global route's result list had no readable count. + ResultListCountUnavailable = 7, + + /// The bounded scan completed without an in-bounds row while Cheat Engine reported an error text. + HostReportedError = 8, + + /// The bounded scan's target was no longer the incarnation it started on. + TargetChanged = 9, + + /// The bounded scan's target could not be qualified during the scan. + TargetIdentityUnavailable = 10, + + /// The Lua runtime changed during the bounded scan. + RuntimeChanged = 11, + + /// CheatEngine.SDK observed the cancellation token between the bounded scan's Cheat Engine calls. + Cancelled = 12 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanMetrics.cs b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanMetrics.cs new file mode 100644 index 0000000..fda69ca --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanMetrics.cs @@ -0,0 +1,184 @@ +namespace CheatEngine.Client.Scanning; + +/// Separates the Cheat Engine cost of one AOB scan from the Client cost of copying its result. +/// +/// +/// The four scan limits are distinct notions (audit ch.24): the Cheat Engine work limit (the bounds on +/// , none on the global routes), the available results +/// (), the materialization limit +/// (, which bounds ), and the +/// call deadline (none: cancellation is observed only between Cheat Engine calls and Client-managed steps +/// and never interrupts a started Cheat Engine scan). +/// +/// +/// Counts and durations are safe to log; they never contain addresses or values. The invariants +/// ExaminedCount + UnreadHostRowCount == HostResultCount, +/// MaterializedCount + FilteredOutCount <= ExaminedCount and +/// BelowStartSkippedCount + AtOrAfterStopSkippedCount <= FilteredOutCount always hold. When a +/// failure stops the copy, the counts describe the work done before the failure. +/// +/// +public readonly record struct PatternScanMetrics +{ + /// Creates validated scan metrics. + /// The part of the target that Cheat Engine scanned. + /// The number of rows Cheat Engine returned, including rows outside the request. + /// The number of rows Core or CheatEngine.SDK read. + /// The number of examined rows outside the module or range. + /// The number of examined rows Core copied into the result. + /// The rows the bounded route dropped below its start bound. + /// The rows the bounded route dropped at or after its stop bound. + /// The rows that were not read, because the copy stopped first. + /// + /// Whether every row was read, so the number of in-request matches is exactly + /// ExaminedCount - FilteredOutCount. + /// + /// The elapsed time of the Cheat Engine scan call only. + /// The elapsed time of the count read, copy, parse, and filter steps. + /// + /// A count or duration is negative, or the scope is undefined. + /// + /// The counts violate the documented invariants. + public PatternScanMetrics(PatternScanScope scope, ulong hostResultCount, ulong examinedCount, + ulong filteredOutCount, int materializedCount, ulong belowStartSkippedCount, ulong atOrAfterStopSkippedCount, + ulong unreadHostRowCount, bool inBoundsCountIsExact, TimeSpan hostScanElapsed, TimeSpan materializationElapsed) + { + if (!Enum.IsDefined(scope)) + { + throw new ArgumentOutOfRangeException(nameof(scope), scope, "The pattern scan scope must be defined."); + } + + ArgumentOutOfRangeException.ThrowIfNegative(materializedCount); + ArgumentOutOfRangeException.ThrowIfLessThan(hostScanElapsed, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(materializationElapsed, TimeSpan.Zero); + if (examinedCount > hostResultCount || unreadHostRowCount != hostResultCount - examinedCount) + { + throw new ArgumentException( + "The examined and unread rows must add up to the rows Cheat Engine returned.", nameof(unreadHostRowCount)); + } + + if (filteredOutCount > examinedCount || (ulong) materializedCount > examinedCount - filteredOutCount) + { + throw new ArgumentException( + "Materialized and filtered-out rows cannot exceed the number of examined rows.", + nameof(materializedCount)); + } + + if (belowStartSkippedCount > filteredOutCount || + atOrAfterStopSkippedCount > filteredOutCount - belowStartSkippedCount) + { + throw new ArgumentException("The bounded route's skipped rows are part of the filtered-out rows.", + nameof(filteredOutCount)); + } + + if (inBoundsCountIsExact && unreadHostRowCount != 0) + { + throw new ArgumentException("An exact in-request count requires every row to be read.", + nameof(inBoundsCountIsExact)); + } + + Scope = scope; + HostResultCount = hostResultCount; + ExaminedCount = examinedCount; + FilteredOutCount = filteredOutCount; + MaterializedCount = materializedCount; + BelowStartSkippedCount = belowStartSkippedCount; + AtOrAfterStopSkippedCount = atOrAfterStopSkippedCount; + UnreadHostRowCount = unreadHostRowCount; + InBoundsCountIsExact = inBoundsCountIsExact; + HostScanElapsed = hostScanElapsed; + MaterializationElapsed = materializationElapsed; + } + + /// Gets the part of the target that Cheat Engine scanned. + public PatternScanScope Scope + { + get; + } + + /// + /// Gets the number of rows Cheat Engine returned (the available results); on the bounded route it includes rows + /// outside the bounds. + /// + public ulong HostResultCount + { + get; + } + + /// Gets the number of rows Core or CheatEngine.SDK read before the copy stopped. + /// Lower than when the materialization limit or a failure stopped the copy. + public ulong ExaminedCount + { + get; + } + + /// + /// Gets the number of examined rows outside the request: removed by the managed module or range filters, or by + /// the bounded route's own start and stop checks. + /// + public ulong FilteredOutCount + { + get; + } + + /// Gets the number of examined rows Core copied into the result. + public int MaterializedCount + { + get; + } + + /// + /// Gets the rows the bounded route dropped because they began below its start: Cheat Engine's start bound is not + /// byte-exact. Zero on the global routes. + /// + public ulong BelowStartSkippedCount + { + get; + } + + /// + /// Gets the rows the bounded route dropped at or after its stop bound; expected to be zero because Cheat Engine + /// honors it. Zero on the global routes. + /// + public ulong AtOrAfterStopSkippedCount + { + get; + } + + /// Gets the rows that were not read because the copy stopped first. + /// + /// A successful scan that left rows unread always reports a result that is not proven complete + /// (). A truncated result can still have read every row, when the one + /// match found beyond the copy was the last row. + /// + public ulong UnreadHostRowCount + { + get; + } + + /// + /// Gets whether every row was read, so the number of in-request matches is exactly + /// ExaminedCount - FilteredOutCount. Uniqueness needs an exact count or a second match, never a first-found + /// scan. + /// + public bool InBoundsCountIsExact + { + get; + } + + /// Gets the elapsed time of the Cheat Engine scan call only (host scan time). + /// + /// On , when a bounded scan had run before + /// CheatEngine.SDK lost the target's identity, it also includes that scan's time: the request cost both scans. + /// + public TimeSpan HostScanElapsed + { + get; + } + + /// Gets the elapsed time spent reading the count, copying, parsing, and filtering the result. + public TimeSpan MaterializationElapsed + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanOutcome.cs b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanOutcome.cs new file mode 100644 index 0000000..15757fd --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanOutcome.cs @@ -0,0 +1,168 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Scanning; + +/// +/// The detailed outcome of one AOB scan: its copied result or failure, the scan metrics, the host's own outcome, why +/// the scan ran on its route, and whether its target identity was verified. +/// +/// +/// +/// Exactly one of and is present. is present on +/// every success, and on a failure whenever Cheat Engine returned a result whose count the Client could read (for +/// example a cancellation observed while copying). It is when no result was obtained, as +/// for the outcome of a global scan that returned +/// nil. +/// +/// +/// and are classified exactly as +/// classifies them for the same request and host behavior. +/// +/// +/// A successful result whose is is not +/// proven complete. says why: +/// counts the rows left unread, and says whether every +/// row was read. +/// +/// +public sealed class PatternScanOutcome +{ + /// Creates a validated scan outcome. + /// The copied result of a successful scan, or on failure. + /// The failure, or on success. + /// The scan metrics; required on success. + /// What Cheat Engine reported for the scan that ran. + /// Why the scan ran on its route. + /// + /// Whether the copied addresses were attributed to one qualified target incarnation for the whole scan. + /// + /// + /// Both or neither of and are present, the failure is not a + /// classified failure, a success has no metrics, the metrics contradict the copied result, a success reports a host + /// outcome other than or + /// or no route, or a failure reports a verified target. + /// + /// + /// or is not a defined value. + /// + public PatternScanOutcome(AobScanResult? result, CheatEngineFailure? failure, PatternScanMetrics? metrics, + PatternScanHostOutcomeKind hostOutcome, PatternScanRouteReason routeReason, bool targetIdentityVerified) + { + if (!Enum.IsDefined(hostOutcome)) + { + throw new ArgumentOutOfRangeException(nameof(hostOutcome), hostOutcome, + "The pattern scan host outcome must be defined."); + } + + if (!Enum.IsDefined(routeReason)) + { + throw new ArgumentOutOfRangeException(nameof(routeReason), routeReason, + "The pattern scan route reason must be defined."); + } + + if (result.HasValue == failure.HasValue) + { + throw new ArgumentException("A pattern scan outcome requires exactly one of a result or a failure.", + nameof(failure)); + } + + if (failure is { } cause) + { + if (string.IsNullOrWhiteSpace(cause.Operation)) + { + throw new ArgumentException("A failed pattern scan outcome requires a classified failure.", + nameof(failure)); + } + + if (targetIdentityVerified) + { + throw new ArgumentException("A failed pattern scan publishes no address to attribute to a target.", + nameof(targetIdentityVerified)); + } + } + + if (result is { } copied) + { + ValidateSuccess(copied, metrics, hostOutcome, routeReason); + } + + Result = result; + Failure = failure; + Metrics = metrics; + HostOutcome = hostOutcome; + RouteReason = routeReason; + TargetIdentityVerified = targetIdentityVerified; + } + + /// Gets whether the scan succeeded. + public bool IsSuccess => Failure is null; + + /// Gets the copied result of a successful scan. + public AobScanResult? Result + { + get; + } + + /// Gets the failure of an unsuccessful scan. + public CheatEngineFailure? Failure + { + get; + } + + /// Gets the host and copy metrics, when Cheat Engine returned a result with a readable count. + public PatternScanMetrics? Metrics + { + get; + } + + /// Gets what Cheat Engine reported for the scan that ran, before the Client decided the result. + public PatternScanHostOutcomeKind HostOutcome + { + get; + } + + /// Gets why the scan ran on its route; when none ran. + public PatternScanRouteReason RouteReason + { + get; + } + + /// + /// Gets whether the copied addresses are attributed to one qualified local target incarnation for the whole scan: + /// always on a successful bounded scan, and on an unscoped global scan only when Cheat Engine's selection was the + /// same qualified incarnation before and after the call. for an unqualified target + /// (CEServer, file as process), on every failure, and always on the + /// route, whose + /// reason it never contradicts. + /// + public bool TargetIdentityVerified + { + get; + } + + private static void ValidateSuccess(AobScanResult copied, PatternScanMetrics? metrics, + PatternScanHostOutcomeKind hostOutcome, PatternScanRouteReason routeReason) + { + if (metrics is not { } measured) + { + throw new ArgumentException("A successful pattern scan outcome requires its metrics.", nameof(metrics)); + } + + if (measured.MaterializedCount != copied.Matches.Length) + { + throw new ArgumentException("The materialized count must equal the number of copied matches.", + nameof(metrics)); + } + + if (hostOutcome is not (PatternScanHostOutcomeKind.Matches or PatternScanHostOutcomeKind.NoMatches)) + { + throw new ArgumentException("A successful pattern scan reports the Matches or NoMatches host outcome.", + nameof(hostOutcome)); + } + + if (routeReason == PatternScanRouteReason.Unknown) + { + throw new ArgumentException("A successful pattern scan reports the route it ran on.", nameof(routeReason)); + } + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanRouteReason.cs b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanRouteReason.cs new file mode 100644 index 0000000..5d123c1 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanRouteReason.cs @@ -0,0 +1,30 @@ +namespace CheatEngine.Client.Scanning; + +/// Why the scan ran on its route (). +public enum PatternScanRouteReason +{ + /// No route was chosen: the request was refused or failed before a scan started. + Unknown = 0, + + /// + /// The request has no module and no range, so Cheat Engine ran one global AOBScan + /// (). + /// + UnscopedRequest = 1, + + /// + /// The request has a module and/or a range and Cheat Engine's selected target is a qualified local process, so + /// Cheat Engine ran the bounded, exhaustive scan (). + /// + ScopedRequestOnQualifiedTarget = 2, + + /// + /// The request has a module and/or a range, but the bounded route could not run: the target was not qualified + /// (a CEServer, file-as-process or unobservable selection), or CheatEngine.SDK could not create the scan session or + /// qualify the target during the scan. Cheat Engine ran the global scan with managed filters + /// (), and the outcome's + /// is always , even when the global + /// scan itself saw one qualified incarnation. + /// + TargetIdentityNotQualified = 3 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanScope.cs b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanScope.cs new file mode 100644 index 0000000..b2e5514 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/PatternScanScope.cs @@ -0,0 +1,30 @@ +namespace CheatEngine.Client.Scanning; + +/// Describes which part of the target Cheat Engine actually scanned for an AOB request. +/// +/// The value names the Cheat Engine work, not the Client filters. A module or range request can still produce +/// when the bounded route could not run: the filters then reduce only +/// what Core copies, never Cheat Engine's scan time or memory. +/// +public enum PatternScanScope +{ + /// The scan scope was not reported. + Unknown = 0, + + /// + /// Cheat Engine ran one global AOBScan over the whole target for a request without a module or range. + /// + GlobalHostScan = 1, + + /// + /// Cheat Engine ran a bounded, exhaustive MemScan over the requested module intersected with the requested range: + /// its work was limited to those bounds. + /// + HostBoundedRange = 2, + + /// + /// Cheat Engine ran one global AOBScan over the whole target for a module or range request whose bounded + /// route could not run; Core applied the module and range filters as managed post-filters while copying. + /// + GlobalHostScanWithManagedFilter = 3 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ScanAlignment.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ScanAlignment.cs new file mode 100644 index 0000000..2bf19c1 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ScanAlignment.cs @@ -0,0 +1,87 @@ +namespace CheatEngine.Client.Scanning; + +/// The alignment rule of a scan: none, a divisor, or required trailing hexadecimal digits. +/// +/// +/// Values come only from , and , which +/// validate and normalize their argument before any scan; the default value is . +/// +/// +/// The rule is a Client value; CheatEngine.SDK's fast-scan method type never appears in a public signature. +/// +/// +public readonly record struct ScanAlignment +{ + private const int MaximumDigits = sizeof(ulong) * 2; + + private ScanAlignment(ScanAlignmentMode mode, int divisor, string? digits) + { + Mode = mode; + Divisor = divisor; + Digits = digits; + } + + /// Gets the rule that checks every address. + public static ScanAlignment None => default; + + /// Gets the kind of rule. + public ScanAlignmentMode Mode + { + get; + } + + /// Gets the divisor of ; zero for any other rule. + public int Divisor + { + get; + } + + /// + /// Gets the upper-case hexadecimal digits of ; + /// for any other rule. + /// + public string? Digits + { + get; + } + + /// Returns the rule that checks only addresses divisible by . + /// The positive divisor, for example 4 for 4-byte aligned values. + /// The alignment rule. + /// is zero or negative. + public static ScanAlignment AlignedTo(int divisor) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(divisor); + return new ScanAlignment(ScanAlignmentMode.AlignedTo, divisor, null); + } + + /// Returns the rule that checks only addresses whose hexadecimal text ends with . + /// One to sixteen hexadecimal digits, in either case. + /// The alignment rule, with the digits in upper case. + /// is . + /// is empty, too long or not hexadecimal. + public static ScanAlignment LastDigits(string digits) + { + ArgumentNullException.ThrowIfNull(digits); + if (digits.Length is 0 or > MaximumDigits) + { + throw new ArgumentException("A last-digits scan requires one to sixteen hexadecimal digits.", + nameof(digits)); + } + + Span normalized = stackalloc char[digits.Length]; + for (int index = 0; index < digits.Length; index++) + { + char character = digits[index]; + normalized[index] = character switch + { + (>= '0' and <= '9') or (>= 'A' and <= 'F') => character, + >= 'a' and <= 'f' => (char) (character - ('a' - 'A')), + _ => throw new ArgumentException("A last-digits scan requires one to sixteen hexadecimal digits.", + nameof(digits)) + }; + } + + return new ScanAlignment(ScanAlignmentMode.LastDigits, 0, new string(normalized)); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ScanAlignmentMode.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ScanAlignmentMode.cs new file mode 100644 index 0000000..7d0cbd6 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ScanAlignmentMode.cs @@ -0,0 +1,23 @@ +namespace CheatEngine.Client.Scanning; + +/// The alignment rule a scan applies to candidate addresses. +/// +/// An option, not an outcome: (zero) is the valid default that checks every address. +/// +public enum ScanAlignmentMode +{ + /// Every address is checked (Cheat Engine's fsmNotAligned). + None = 0, + + /// + /// Only addresses divisible by are checked (Cheat Engine's + /// fsmAligned). + /// + AlignedTo = 1, + + /// + /// Only addresses whose hexadecimal text ends with are checked (Cheat Engine's + /// fsmLastDigits). + /// + LastDigits = 2 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ScanProtectionFilter.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ScanProtectionFilter.cs new file mode 100644 index 0000000..8f172db --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ScanProtectionFilter.cs @@ -0,0 +1,60 @@ +namespace CheatEngine.Client.Scanning; + +/// The memory protection a scan requires, one tri-state requirement per Cheat Engine protection flag. +/// +/// +/// Core passes the filter to Cheat Engine as its protection text, in the order executable, copy-on-write, +/// writable: for example executable, not copy-on-write and not writable memory is +X-C-W. The default +/// value leaves every flag unspecified and is passed as the empty text, Cheat Engine's documented "find +/// everything" value, on every route. +/// +/// +/// The filter is a Client value; CheatEngine.SDK's option type never appears in a public signature. +/// +/// +public readonly record struct ScanProtectionFilter +{ + /// Creates a protection filter. + /// The requirement on the executable flag (X). + /// The requirement on the copy-on-write flag (C). + /// The requirement on the writable flag (W). + /// A requirement is not a defined value. + public ScanProtectionFilter(ScanProtectionRequirement executable, ScanProtectionRequirement copyOnWrite, + ScanProtectionRequirement writable) + { + Executable = Validate(executable, nameof(executable)); + CopyOnWrite = Validate(copyOnWrite, nameof(copyOnWrite)); + Writable = Validate(writable, nameof(writable)); + } + + /// Gets the requirement on the executable flag (X). + public ScanProtectionRequirement Executable + { + get; + } + + /// Gets the requirement on the copy-on-write flag (C). + public ScanProtectionRequirement CopyOnWrite + { + get; + } + + /// Gets the requirement on the writable flag (W). + public ScanProtectionRequirement Writable + { + get; + } + + /// Gets whether every flag is . + public bool IsUnspecified => Executable == ScanProtectionRequirement.Unspecified && + CopyOnWrite == ScanProtectionRequirement.Unspecified && + Writable == ScanProtectionRequirement.Unspecified; + + private static ScanProtectionRequirement Validate(ScanProtectionRequirement requirement, string parameterName) + { + return Enum.IsDefined(requirement) + ? requirement + : throw new ArgumentOutOfRangeException(parameterName, requirement, + "A scan protection requirement must be a defined value."); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ScanProtectionRequirement.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ScanProtectionRequirement.cs new file mode 100644 index 0000000..45e20c0 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ScanProtectionRequirement.cs @@ -0,0 +1,22 @@ +namespace CheatEngine.Client.Scanning; + +/// What a scan requires of one memory protection flag (executable, copy-on-write or writable). +/// +/// The values follow Cheat Engine's protection grammar: + requires the flag, - excludes it and +/// * accepts either. leaves the flag out of the request, which Cheat Engine treats +/// as "either". Cheat Engine's grammar has no readable flag: every scanned page is readable. +/// +public enum ScanProtectionRequirement +{ + /// The flag is left out of the request; Cheat Engine accepts memory with or without it. + Unspecified = 0, + + /// The flag must be set (+). + Required = 1, + + /// The flag must not be set (-). + Excluded = 2, + + /// The flag is explicitly accepted either way (*). + Any = 3 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanComparison.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanComparison.cs new file mode 100644 index 0000000..d396326 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanComparison.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Scanning; + +/// What a first or next value scan compares. +/// +/// A first scan accepts , , , +/// and ; a next scan accepts every value except +/// . Build a request with the factories of or +/// , which choose the comparison. +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public enum ValueScanComparison +{ + /// Equal to the value. + Exact = 0, + + /// Between the value and the upper value, both included. + Between = 1, + + /// Greater than the value. + BiggerThan = 2, + + /// Less than the value. + SmallerThan = 3, + + /// Every address of the value type, without a comparison (first scan only). + UnknownInitialValue = 4, + + /// Greater than in the previous scan (next scan only). + Increased = 5, + + /// Greater than in the previous scan by exactly the value (next scan only). + IncreasedBy = 6, + + /// Less than in the previous scan (next scan only). + Decreased = 7, + + /// Less than in the previous scan by exactly the value (next scan only). + DecreasedBy = 8, + + /// Different from the previous scan (next scan only). + Changed = 9, + + /// Equal to the previous scan (next scan only). + Unchanged = 10 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanFirstRequest.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanFirstRequest.cs new file mode 100644 index 0000000..58c5865 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanFirstRequest.cs @@ -0,0 +1,214 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// A first value scan: its comparison, its value type, the scanned range and the region filters. +/// +/// +/// Create a request with , , , +/// or ; it scans the whole address space with no +/// protection filter and no alignment until , or +/// narrows it. +/// +/// +/// On the pinned Cheat Engine 7.7 profile the stop address is exclusive: a match is reported only when it fits +/// entirely below . The start address is not byte-exact: a match that begins slightly +/// before can be reported, so check when an exact +/// start matters. A request has no value and an empty range: every session throws an +/// for it, before the activation check and before any Cheat Engine call. +/// +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly record struct ValueScanFirstRequest +{ + private ValueScanFirstRequest(ValueScanComparison comparison, ValueScanValueType valueType, ValueScanValue? value, + ValueScanValue? upperValue, Address startAddress, Address stopAddress, ScanProtectionFilter protection, + ScanAlignment alignment) + { + Comparison = comparison; + ValueType = valueType; + Value = value; + UpperValue = upperValue; + StartAddress = startAddress; + StopAddress = stopAddress; + Protection = protection; + Alignment = alignment; + } + + /// Gets the comparison of the scan. + public ValueScanComparison Comparison + { + get; + } + + /// Gets the type Cheat Engine compares; later next scans of the session compare the same type. + public ValueScanValueType ValueType + { + get; + } + + /// Gets the compared value, or the lower bound of . + /// for . + public ValueScanValue? Value + { + get; + } + + /// Gets the upper bound of , or . + public ValueScanValue? UpperValue + { + get; + } + + /// Gets the lower bound of the scanned range; not byte-exact on Cheat Engine 7.7. + public Address StartAddress + { + get; + } + + /// Gets the exclusive upper bound of the scanned range. + public Address StopAddress + { + get; + } + + /// Gets the protection attributes the scanned regions must have or lack. + public ScanProtectionFilter Protection + { + get; + } + + /// Gets the address-alignment rule of the scan. + public ScanAlignment Alignment + { + get; + } + + /// Scans for addresses whose value equals . + /// The compared value, of any type. + /// The request. + /// is a value. + public static ValueScanFirstRequest Exact(ValueScanValue value) + { + RequireValue(value, nameof(value)); + return Create(ValueScanComparison.Exact, value.ValueType, value, null); + } + + /// Scans for addresses whose value is between two bounds, both included. + /// The numeric lower bound. + /// The upper bound, of the same type as . + /// The request. + /// + /// A bound is a value or not numeric, or the bounds have different types. + /// + public static ValueScanFirstRequest Between(ValueScanValue lowest, ValueScanValue highest) + { + RequireNumeric(lowest, nameof(lowest)); + RequireNumeric(highest, nameof(highest)); + if (lowest.ValueType != highest.ValueType) + { + throw new ArgumentException("Both bounds of a value scan must have the same type.", nameof(highest)); + } + + return Create(ValueScanComparison.Between, lowest.ValueType, lowest, highest); + } + + /// Scans for addresses whose value is greater than . + /// The numeric compared value. + /// The request. + /// is a value or not numeric. + public static ValueScanFirstRequest BiggerThan(ValueScanValue value) + { + RequireNumeric(value, nameof(value)); + return Create(ValueScanComparison.BiggerThan, value.ValueType, value, null); + } + + /// Scans for addresses whose value is less than . + /// The numeric compared value. + /// The request. + /// is a value or not numeric. + public static ValueScanFirstRequest SmallerThan(ValueScanValue value) + { + RequireNumeric(value, nameof(value)); + return Create(ValueScanComparison.SmallerThan, value.ValueType, value, null); + } + + /// Records every address of a numeric type without comparing, for a later next scan. + /// A numeric value type. + /// The request. + /// is not a numeric value type. + public static ValueScanFirstRequest UnknownInitialValue(ValueScanValueType valueType) + { + if (valueType is < ValueScanValueType.Integer8 or > ValueScanValueType.DoubleFloat) + { + throw new ArgumentOutOfRangeException(nameof(valueType), valueType, + "An unknown initial value scan requires a numeric value type."); + } + + return Create(ValueScanComparison.UnknownInitialValue, valueType, null, null); + } + + /// Returns the request narrowed to [startAddress, stopAddress). + /// The lower bound; not byte-exact on Cheat Engine 7.7. + /// The exclusive upper bound. + /// The narrowed request. + /// + /// is not greater than . + /// + public ValueScanFirstRequest WithRange(Address startAddress, Address stopAddress) + { + if (stopAddress <= startAddress) + { + throw new ArgumentOutOfRangeException(nameof(stopAddress), stopAddress, + "A value scan range must be non-empty: the exclusive stop address must be greater than the start address."); + } + + return new ValueScanFirstRequest(Comparison, ValueType, Value, UpperValue, startAddress, stopAddress, Protection, + Alignment); + } + + /// Returns the request with a protection filter. + /// The protection attributes the scanned regions must have or lack. + /// The filtered request. + public ValueScanFirstRequest WithProtection(ScanProtectionFilter protection) + { + return new ValueScanFirstRequest(Comparison, ValueType, Value, UpperValue, StartAddress, StopAddress, protection, + Alignment); + } + + /// Returns the request with an address-alignment rule. + /// The alignment rule. + /// The aligned request. + public ValueScanFirstRequest WithAlignment(ScanAlignment alignment) + { + return new ValueScanFirstRequest(Comparison, ValueType, Value, UpperValue, StartAddress, StopAddress, Protection, + alignment); + } + + private static ValueScanFirstRequest Create(ValueScanComparison comparison, ValueScanValueType valueType, + ValueScanValue? value, ValueScanValue? upperValue) + { + return new ValueScanFirstRequest(comparison, valueType, value, upperValue, Address.Zero, + new Address(ulong.MaxValue), default, ScanAlignment.None); + } + + private static void RequireValue(ValueScanValue value, string parameterName) + { + if (value.Text is null) + { + throw new ArgumentException("A scanned value must be created by a ValueScanValue factory.", + parameterName); + } + } + + private static void RequireNumeric(ValueScanValue value, string parameterName) + { + RequireValue(value, parameterName); + if (!value.IsNumeric) + { + throw new ArgumentException("This comparison accepts only a numeric value.", parameterName); + } + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanInvalidationKind.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanInvalidationKind.cs new file mode 100644 index 0000000..99c51c5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanInvalidationKind.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Scanning; + +/// Why a value-scan session was invalidated, as reports it. +/// A value this version does not define reads like . +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public enum ValueScanInvalidationKind +{ + /// The reason could not be established. + Unknown = 0, + + /// The session is not invalidated. + None = 1, + + /// A Cheat Engine call of the scan, wait, reset or result sequence began and did not complete. + HostCallFailed = 2, + + /// The Lua runtime that owns the session's Cheat Engine objects was replaced; only the release remains. + RuntimeChanged = 3, + + /// + /// Cheat Engine selected another process, or another incarnation of the same process identifier, than the one the + /// session was created for; only the release remains. + /// + TargetChanged = 4 +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs index b87698b..2821983 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs @@ -1,35 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Scanning; -/// A copied value-scan match; it owns no FoundList or other CE resource. +/// One copied value-scan result: an address and the value text Cheat Engine displays for it. +/// +/// The match owns no Cheat Engine object and stays valid after its session is released. is +/// Cheat Engine's text, formatted by Cheat Engine for the scanned type: to read the typed value, read the address +/// again, for example with IMemoryClient.ReadPrimitive<int>(match.Address). The address and the text +/// are user data: log them only on an explicit opt-in. +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public readonly record struct ValueScanMatch { + private readonly string? _valueText; + /// Creates a copied value-scan match. - public ValueScanMatch(int index, Address address, string value) + /// The target address of the match. + /// The value text Cheat Engine returned for the match. + /// is . + public ValueScanMatch(Address address, string valueText) { - ArgumentOutOfRangeException.ThrowIfNegative(index); - ArgumentNullException.ThrowIfNull(value); - Index = index; + ArgumentNullException.ThrowIfNull(valueText); Address = address; - Value = value; + _valueText = valueText; } - /// Gets the zero-based index in the CE found list. - public int Index - { - get; - } - - /// Gets the parsed target address. + /// Gets the target address of the match. public Address Address { get; } - /// Gets the verbatim value text returned by Cheat Engine. - public string Value - { - get; - } + /// Gets the value text Cheat Engine returned for the match, verbatim. + /// for the value. + public string ValueText => _valueText ?? string.Empty; } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanNextRequest.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanNextRequest.cs new file mode 100644 index 0000000..7b7526a --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanNextRequest.cs @@ -0,0 +1,159 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Scanning; + +/// A next value scan: a comparison over the results of the previous scan of the same session. +/// +/// A next scan compares the value type of the session's first scan. A value it carries must have that type: another +/// type is refused with before any Cheat Engine +/// call. A request has no value: every session throws an +/// for it, before the activation check and before any Cheat Engine call. +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly record struct ValueScanNextRequest +{ + private ValueScanNextRequest(ValueScanComparison comparison, ValueScanValue? value, ValueScanValue? upperValue) + { + Comparison = comparison; + Value = value; + UpperValue = upperValue; + } + + /// Gets the comparison of the scan. + public ValueScanComparison Comparison + { + get; + } + + /// Gets the compared value, the lower bound of a range, or . + /// + /// for , + /// , and + /// , which compare with the previous scan. + /// + public ValueScanValue? Value + { + get; + } + + /// Gets the upper bound of , or . + public ValueScanValue? UpperValue + { + get; + } + + /// Keeps the results whose value equals . + /// The compared value. + /// The request. + /// is a value. + public static ValueScanNextRequest Exact(ValueScanValue value) + { + RequireValue(value, nameof(value)); + return new ValueScanNextRequest(ValueScanComparison.Exact, value, null); + } + + /// Keeps the results whose value is between two bounds, both included. + /// The numeric lower bound. + /// The upper bound, of the same type as . + /// The request. + /// + /// A bound is a value or not numeric, or the bounds have different types. + /// + public static ValueScanNextRequest Between(ValueScanValue lowest, ValueScanValue highest) + { + RequireNumeric(lowest, nameof(lowest)); + RequireNumeric(highest, nameof(highest)); + if (lowest.ValueType != highest.ValueType) + { + throw new ArgumentException("Both bounds of a value scan must have the same type.", nameof(highest)); + } + + return new ValueScanNextRequest(ValueScanComparison.Between, lowest, highest); + } + + /// Keeps the results whose value is greater than . + /// The numeric compared value. + /// The request. + /// is a value or not numeric. + public static ValueScanNextRequest BiggerThan(ValueScanValue value) + { + RequireNumeric(value, nameof(value)); + return new ValueScanNextRequest(ValueScanComparison.BiggerThan, value, null); + } + + /// Keeps the results whose value is less than . + /// The numeric compared value. + /// The request. + /// is a value or not numeric. + public static ValueScanNextRequest SmallerThan(ValueScanValue value) + { + RequireNumeric(value, nameof(value)); + return new ValueScanNextRequest(ValueScanComparison.SmallerThan, value, null); + } + + /// Keeps the results whose value grew since the previous scan. + /// The request. + public static ValueScanNextRequest Increased() + { + return new ValueScanNextRequest(ValueScanComparison.Increased, null, null); + } + + /// Keeps the results whose value grew by exactly since the previous scan. + /// The numeric difference. + /// The request. + /// is a value or not numeric. + public static ValueScanNextRequest IncreasedBy(ValueScanValue value) + { + RequireNumeric(value, nameof(value)); + return new ValueScanNextRequest(ValueScanComparison.IncreasedBy, value, null); + } + + /// Keeps the results whose value shrank since the previous scan. + /// The request. + public static ValueScanNextRequest Decreased() + { + return new ValueScanNextRequest(ValueScanComparison.Decreased, null, null); + } + + /// Keeps the results whose value shrank by exactly since the previous scan. + /// The numeric difference. + /// The request. + /// is a value or not numeric. + public static ValueScanNextRequest DecreasedBy(ValueScanValue value) + { + RequireNumeric(value, nameof(value)); + return new ValueScanNextRequest(ValueScanComparison.DecreasedBy, value, null); + } + + /// Keeps the results whose value differs from the previous scan. + /// The request. + public static ValueScanNextRequest Changed() + { + return new ValueScanNextRequest(ValueScanComparison.Changed, null, null); + } + + /// Keeps the results whose value equals the previous scan. + /// The request. + public static ValueScanNextRequest Unchanged() + { + return new ValueScanNextRequest(ValueScanComparison.Unchanged, null, null); + } + + private static void RequireValue(ValueScanValue value, string parameterName) + { + if (value.Text is null) + { + throw new ArgumentException("A scanned value must be created by a ValueScanValue factory.", + parameterName); + } + } + + private static void RequireNumeric(ValueScanValue value, string parameterName) + { + RequireValue(value, parameterName); + if (!value.IsNumeric) + { + throw new ArgumentException("This comparison accepts only a numeric value.", parameterName); + } + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs index 402ffdd..3da37de 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs @@ -1,26 +1,50 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; namespace CheatEngine.Client.Scanning; /// An immutable page of copied value-scan results. -public readonly record struct ValueScanPage +/// +/// A page is copied in full or not at all: a read that fails publishes no page, never a prefix. A scan without +/// results reads as an empty page whose is zero. +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly struct ValueScanPage { + private readonly ImmutableArray _matches; + /// Creates a value-scan result page. - public ValueScanPage(ulong totalCount, ImmutableArray matches) + /// The zero-based index of the first match of the page. + /// The number of results Cheat Engine reported when the page was copied. + /// The copied matches; a array is read as empty. + /// is negative. + public ValueScanPage(long startIndex, ulong resultCount, ImmutableArray matches) { - TotalCount = totalCount; - Matches = matches.IsDefault ? ImmutableArray.Empty : matches; + ArgumentOutOfRangeException.ThrowIfNegative(startIndex); + StartIndex = startIndex; + ResultCount = resultCount; + _matches = matches.IsDefault ? [] : matches; } - /// Gets the total CE result count observed while reading this page. - public ulong TotalCount + /// Gets the zero-based index of the first match of the page. + public long StartIndex { get; } - /// Gets the copied result matches. - public ImmutableArray Matches + /// Gets the number of results Cheat Engine reported when the page was copied. + public ulong ResultCount { get; } + + /// Gets the copied matches, in Cheat Engine's result order. + /// Empty for the value, never a default array. + public ImmutableArray Matches => _matches.IsDefault ? [] : _matches; + + /// Gets the index that follows the last match of the page: the start of the next page. + public long NextStartIndex => StartIndex + Matches.Length; + + /// Gets whether results follow this page. + public bool HasMore => (ulong) NextStartIndex < ResultCount; } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs index aa76e9e..ffaec48 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs @@ -1,10 +1,24 @@ +using System.Diagnostics.CodeAnalysis; + namespace CheatEngine.Client.Scanning; -/// Describes a bounded, zero-based value-scan result read. +/// A bounded, zero-based read of the current value-scan results. +/// +/// Cheat Engine addresses its result list with a 32-bit index: a above +/// is refused with +/// before any Cheat Engine call. One read copies at most results and never more than the +/// Client's page limit (1024 results); read the next page from . +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public readonly record struct ValueScanReadRequest { - /// Creates a bounded result-read request. - public ValueScanReadRequest(int startIndex, int maximumCount) + /// Creates a bounded result read. + /// The zero-based index of the first result to copy. + /// The positive maximum number of results to copy. + /// + /// is negative, or is zero or negative. + /// + public ValueScanReadRequest(long startIndex, int maximumCount) { ArgumentOutOfRangeException.ThrowIfNegative(startIndex); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); @@ -12,13 +26,13 @@ public ValueScanReadRequest(int startIndex, int maximumCount) MaximumCount = maximumCount; } - /// Gets the first zero-based result index to read. - public int StartIndex + /// Gets the zero-based index of the first result to copy. + public long StartIndex { get; } - /// Gets the maximum number of copied matches to materialize. + /// Gets the maximum number of results to copy. public int MaximumCount { get; diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs index 97535e1..5051b9d 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs @@ -1,33 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + namespace CheatEngine.Client.Scanning; -/// Describes the conservative Client-managed lifecycle of one value-scan session. +/// The conservative lifecycle state of one value-scan session. /// /// -/// The successful path is , , then -/// . A subsequent scan transitions from through -/// again. No SDK object or Lua handle is represented by this value. +/// The successful path is , then after each first or next scan; +/// a reset returns to . The state is the one Cheat Engine's scan session reported after the +/// last operation; it is a copy that no Lua handle backs. /// /// -/// is deliberately conservative. It means a Cheat Engine operation began but failed -/// before -/// the Client could establish a safe next state; the session must be reset or disposed rather than reused -/// speculatively. +/// is observed only when a scan was started and the Client could not wait for it (a +/// cancellation between the two steps): the session then accepts nothing but its release, which asks Cheat Engine +/// to stop the scan. means a Cheat Engine call began and did not establish a safe next +/// state (see ): reset the session, or release it when the reset is +/// refused. A value this version does not define reads like . /// /// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] public enum ValueScanSessionState { - /// The session was created and has no readable result set. - Created = 0, + /// The state could not be established. + Unknown = 0, + + /// The session has no scan result: it accepts a first scan. + Created = 1, - /// A first or subsequent scan was accepted and has not yet completed. - Scanning = 1, + /// A scan was started and has not completed; only the release is accepted. + Scanning = 2, - /// The scan completed and its copied-result view is available for bounded reads. - ResultsReady = 2, + /// The last scan completed: its results can be counted and read, and a next scan or a reset is accepted. + ResultsReady = 3, - /// A started operation did not establish a safe continuation state. - Invalidated = 3, + /// A Cheat Engine call began without establishing a safe next state: reset or release the session. + Invalidated = 4, - /// The session has released its Client-owned resources and accepts no further operation. - Disposed = 4 + /// + /// The session accepts no operation: its Cheat Engine objects were released or given up. + /// and say what + /// the release did; a release that could not begin leaves the session closed while its lease stays active. + /// + Closed = 5 } diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanValue.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanValue.cs new file mode 100644 index 0000000..cd1cee5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanValue.cs @@ -0,0 +1,184 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace CheatEngine.Client.Scanning; + +/// A typed value that a value scan compares, with the exact text passed to Cheat Engine. +/// +/// +/// Create a value with a typed factory: it chooses the and formats the text with +/// the invariant culture. Integers are decimal; writes each byte as two hexadecimal digits +/// separated by spaces. The wider integer factories take signed values: pass an unsigned value as its +/// bit-identical signed value, for example unchecked((int)value). An exact comparison matches the same bits +/// either way; an ordered comparison follows Cheat Engine's own rules. +/// +/// +/// Floating-point precision. and write the value in +/// fixed-point notation, never in exponent notation, rounded to the number of decimals the caller passes, with a +/// . separator: FromDouble(100, 2) is 100.00. Cheat Engine's rounded exact comparison takes +/// its precision from the digits of that text: its Lua documentation (rtRounded) states that 3 +/// matches 3.0 to 3.4999 and 3.0 matches 3.00 to 3.0499, and does not say whether a value just below the +/// text (2.6 for 3) matches as well, as ordinary rounding to that many decimals would. Choose the decimals +/// the scan needs, knowing that each one fewer widens the match tenfold. No Client receipt covers this tolerance +/// yet: Q25 records which of the two rules the host applies. +/// +/// +/// A value has no : every scan request factory throws an +/// for it. The text is user data: log it only on an explicit opt-in. +/// +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public readonly record struct ValueScanValue +{ + private const int MaximumFloatDecimals = 15; + + private ValueScanValue(ValueScanValueType valueType, string text) + { + ValueType = valueType; + Text = text; + } + + /// + /// Gets the type Cheat Engine compares; a first scan takes its + /// from its value. + /// + public ValueScanValueType ValueType + { + get; + } + + /// Gets the exact text passed to Cheat Engine, or for a default value. + public string? Text + { + get; + } + + /// Gets whether the value is a numeric type, which ordered comparisons and unknown initial values accept. + public bool IsNumeric => ValueType is >= ValueScanValueType.Integer8 and <= ValueScanValueType.DoubleFloat; + + /// Creates a one-byte value. + /// The value. + /// A value. + public static ValueScanValue FromByte(byte value) + { + return new ValueScanValue(ValueScanValueType.Integer8, value.ToString(CultureInfo.InvariantCulture)); + } + + /// Creates a two-byte value. + /// The value. + /// A value. + public static ValueScanValue FromInt16(short value) + { + return new ValueScanValue(ValueScanValueType.Integer16, value.ToString(CultureInfo.InvariantCulture)); + } + + /// Creates a four-byte value. + /// The value. + /// A value. + public static ValueScanValue FromInt32(int value) + { + return new ValueScanValue(ValueScanValueType.Integer32, value.ToString(CultureInfo.InvariantCulture)); + } + + /// Creates an eight-byte value. + /// The value. + /// A value. + public static ValueScanValue FromInt64(long value) + { + return new ValueScanValue(ValueScanValueType.Integer64, value.ToString(CultureInfo.InvariantCulture)); + } + + /// Creates a single-precision value, written with a fixed number of decimals. + /// A finite value. + /// + /// The number of decimals, from 0 to 15, which Cheat Engine's rounded exact comparison uses as its precision. + /// + /// A value. + /// + /// is not finite, or is outside 0 to 15. + /// + public static ValueScanValue FromSingle(float value, int decimals) + { + if (!float.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "A scanned floating-point value must be finite."); + } + + return new ValueScanValue(ValueScanValueType.SingleFloat, + value.ToString(FixedPointFormat(decimals), CultureInfo.InvariantCulture)); + } + + /// Creates a double-precision value, written with a fixed number of decimals. + /// A finite value. + /// + /// The number of decimals, from 0 to 15, which Cheat Engine's rounded exact comparison uses as its precision. + /// + /// A value. + /// + /// is not finite, or is outside 0 to 15. + /// + public static ValueScanValue FromDouble(double value, int decimals) + { + if (!double.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "A scanned floating-point value must be finite."); + } + + return new ValueScanValue(ValueScanValueType.DoubleFloat, + value.ToString(FixedPointFormat(decimals), CultureInfo.InvariantCulture)); + } + + /// Creates a case-sensitive UTF-8 text. + /// The non-empty text. + /// A value. + /// is or empty. + public static ValueScanValue FromUtf8String(string text) + { + ArgumentException.ThrowIfNullOrEmpty(text); + return new ValueScanValue(ValueScanValueType.Utf8String, text); + } + + /// Creates a case-sensitive UTF-16 text. + /// The non-empty text. + /// A value. + /// is or empty. + public static ValueScanValue FromUtf16String(string text) + { + ArgumentException.ThrowIfNullOrEmpty(text); + return new ValueScanValue(ValueScanValueType.Utf16String, text); + } + + /// Creates an exact byte sequence. + /// The non-empty bytes, in target memory order. + /// A value. + /// is empty. + public static ValueScanValue FromBytes(ReadOnlySpan bytes) + { + if (bytes.IsEmpty) + { + throw new ArgumentException("A scanned byte sequence must not be empty.", nameof(bytes)); + } + + StringBuilder text = new((bytes.Length * 3) - 1); + for (int index = 0; index < bytes.Length; index++) + { + if (index > 0) + { + _ = text.Append(' '); + } + + _ = text.Append(bytes[index].ToString("X2", CultureInfo.InvariantCulture)); + } + + return new ValueScanValue(ValueScanValueType.ByteArray, text.ToString()); + } + + /// Returns the fixed-point format of a number of decimals, which is never exponent notation. + private static string FixedPointFormat(int decimals) + { + ArgumentOutOfRangeException.ThrowIfNegative(decimals); + ArgumentOutOfRangeException.ThrowIfGreaterThan(decimals, MaximumFloatDecimals); + return "F" + decimals.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanValueType.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanValueType.cs new file mode 100644 index 0000000..89edf7c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanValueType.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Scanning; + +/// The kind of value a value scan compares, in Cheat Engine's terms. +/// +/// A numeric type selects the width Cheat Engine compares (one, two, four or eight bytes, or an IEEE single or double); +/// the text types select a UTF-8 or UTF-16 string scan, and an exact byte sequence. +/// +[Experimental(ClientExperimentalDiagnostics.ValueScans, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] +public enum ValueScanValueType +{ + /// A one-byte integer (Cheat Engine vtByte). + Integer8 = 0, + + /// A two-byte integer (Cheat Engine vtWord). + Integer16 = 1, + + /// A four-byte integer (Cheat Engine vtDword). + Integer32 = 2, + + /// An eight-byte integer (Cheat Engine vtQword). + Integer64 = 3, + + /// An IEEE 754 single-precision value (Cheat Engine vtSingle). + SingleFloat = 4, + + /// An IEEE 754 double-precision value (Cheat Engine vtDouble). + DoubleFloat = 5, + + /// A UTF-8 text (Cheat Engine vtString). + Utf8String = 6, + + /// A UTF-16 text (Cheat Engine vtString with its Unicode option). + Utf16String = 7, + + /// An exact sequence of bytes (Cheat Engine vtByteArray). + ByteArray = 8 +} diff --git a/libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs b/libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs deleted file mode 100644 index 292bc7e..0000000 --- a/libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Speed; - -/// Reads and updates the selected target's validated speed multiplier. -public interface ISpeedClient -{ - /// Tries to get the selected target's current speed multiplier. - public bool TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Gets the selected target's current speed multiplier or throws when unavailable. - public SpeedMultiplier GetMultiplier(CancellationToken cancellationToken = default); - - /// Tries to set a finite, strictly positive speed multiplier. - public bool TrySetMultiplier(SpeedMultiplier multiplier, out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Sets a finite, strictly positive speed multiplier or throws when unavailable. - public void SetMultiplier(SpeedMultiplier multiplier, CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs b/libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs deleted file mode 100644 index a8a63d1..0000000 --- a/libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CheatEngine.Client.Speed; - -/// Represents a finite, strictly positive Client speed multiplier. -public readonly record struct SpeedMultiplier -{ - /// Creates a speed multiplier. - /// is non-finite or not positive. - public SpeedMultiplier(double value) - { - if (!double.IsFinite(value) || value <= 0) - { - throw new ArgumentOutOfRangeException(nameof(value), - "A speed multiplier must be finite and strictly positive."); - } - - Value = value; - } - - /// Gets the finite, strictly positive multiplier value. - public double Value - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs index 2a6e124..fc30696 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs @@ -2,37 +2,28 @@ namespace CheatEngine.Client.Tables; -/// A copied snapshot of Cheat Engine's current address-list cardinality. -public readonly record struct AddressTableSnapshot +/// A bounded copied snapshot of every top-level record in Cheat Engine's current address list. +/// +/// copies it under an explicit materialization limit. Use +/// to read the number of top-level records without copying them. +/// +public readonly struct AddressTableSnapshot { - /// Creates an address-table snapshot. - public AddressTableSnapshot(int recordCount) - { - ArgumentOutOfRangeException.ThrowIfNegative(recordCount); - RecordCount = recordCount; - Records = ImmutableArray.Empty; - } + private readonly ImmutableArray _records; /// Creates a bounded copied snapshot of every top-level record in the address list. + /// The copied top-level records; a default array is treated as empty. public AddressTableSnapshot(ImmutableArray records) { - Records = records.IsDefault ? ImmutableArray.Empty : records; - RecordCount = Records.Length; + _records = records.IsDefault ? ImmutableArray.Empty : records; } - /// Gets the number of top-level records observed in the current address list. - public int RecordCount - { - get; - } + /// Gets the number of copied top-level records. + /// 0 for the value. + public int RecordCount => Records.Length; - /// Gets the copied top-level records when this snapshot was explicitly materialized. - /// - /// A cardinality-only value returned by has an empty collection. Use - /// to request records with an explicit materialization limit. - /// - public ImmutableArray Records - { - get; - } + /// Gets the copied top-level records. + /// Empty for the value, never a default array. + public ImmutableArray Records => + _records.IsDefault ? ImmutableArray.Empty : _records; } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs b/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs index b8849f9..3feafd8 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs @@ -6,120 +6,674 @@ namespace CheatEngine.Client.Tables; /// Reads and changes the current Cheat Engine address list through copied record snapshots. +/// +/// +/// Call-only. The Client implements this interface and applications call it. A minor release can add +/// members to it, so implement it only in a test double. +/// +/// +/// Memory records are live Cheat Engine objects; a snapshot copies them and does not freeze them. A +/// is valid only for the table load and the activation in which this client handed it +/// out: after a trusted table load that reached Cheat Engine (merge or replace, even a failed one), every +/// identifier-taking operation refuses an identifier captured earlier with +/// and , until a +/// later snapshot observes it again. A reload by the user, a script or another plugin is not detected; the record is +/// then revalidated at mutation time and an absent record is reported as +/// , distinct from a host error. +/// +/// +/// Concurrent callers: the table load, every snapshot copy and every identifier check are ordered on Cheat Engine's +/// main thread, not by the order in which the calling threads resume. A snapshot copied before a concurrent trusted +/// load hands out identifiers that are already refused, and an identifier-taking operation queued behind an +/// in-flight load is checked again on the main thread and refused without calling Cheat Engine. +/// +/// +/// A argument is a programming error, thrown before the activation check and before +/// any Cheat Engine call for the first field its check meets: an +/// for a or +/// , which allows no record, and an +/// for a , , +/// , or , which +/// searches, creates, changes or names nothing. A value type that is not a defined value throws an +/// . +/// +/// +/// After its arguments, every member checks the activation: an ended activation throws +/// and a stopping one +/// , except that a deactivation callback can still call every +/// member but the trusted table load and save on Cheat Engine's main thread (see +/// ). A Try member returns every other failure; the throwing member +/// with the same inputs throws it through . +/// +/// +/// Trusted table files follow the Client path policy. Cheat Engine's file form of loadTable offers no +/// option to suppress a table's Lua scripts, so a table with scripts may prompt or execute Lua. A refused path is +/// never retried through another overload and never turned into a stream. +/// +/// public interface ITableClient { - /// Tries to get a snapshot of the current address list. - public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure failure, + /// Tries to count the top-level records of the current address list without copying them. + /// The number of top-level records on success; otherwise zero. + /// The classified failure; the default value on success. + /// Observed before the count is dispatched to Cheat Engine's main thread. + /// when the records were counted. + /// Use to copy the records under an explicit materialization limit. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryGetRecordCount(out int recordCount, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Gets the current table or throws when it is unavailable. - public AddressTableSnapshot GetCurrent(CancellationToken cancellationToken = default); + /// Counts the top-level records of the current address list or throws when it is unavailable. + /// Observed before the count is dispatched to Cheat Engine's main thread. + /// The number of top-level records. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the count failed with + /// . + /// + /// + /// The count observed the cancellation of . + /// + /// The count failed with any other failure kind. + public int GetRecordCount(CancellationToken cancellationToken = default); /// Copies every top-level record only when the caller supplies a materialization limit. + /// The largest number of top-level records to copy. + /// The copied top-level records on success; otherwise the default value. + /// + /// The classified failure, when the list holds more + /// records than allows; the default value on success. + /// + /// Observed before the copy is dispatched to Cheat Engine's main thread. + /// when every top-level record was copied. + /// + /// is the request, which allows no record. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Copies a bounded top-level record snapshot or throws when the table exceeds the limit. + /// The largest number of top-level records to copy. + /// Observed before the copy is dispatched to Cheat Engine's main thread. + /// The copied top-level records. + /// + /// is the request, which allows no record. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the copy failed with + /// . + /// + /// + /// The copy observed the cancellation of . + /// + /// + /// The copy failed with any other failure kind, + /// included. + /// public AddressTableSnapshot GetSnapshot(MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default); /// Searches a bounded copied top-level record snapshot with conjunctive managed predicates. + /// The predicates every returned record matches. + /// The largest number of top-level records to copy before the search. + /// The matching records, in address-list order, on success; otherwise an empty array. + /// The classified failure; the default value on success. + /// + /// Observed before the copy is dispatched to Cheat Engine's main thread, and again before the copy is searched. + /// + /// when the top-level records were copied and searched. + /// + /// is the search, which has no predicate, or + /// is the request (an + /// , as for a value type that is not a defined value). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryFind(MemoryRecordSearch search, MemoryRecordCollectionRequest request, out ImmutableArray records, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Searches a bounded top-level record snapshot or throws when it cannot be materialized. + /// The predicates every returned record matches. + /// The largest number of top-level records to copy before the search. + /// + /// Observed before the copy is dispatched to Cheat Engine's main thread, and again before the copy is searched. + /// + /// The matching records, in address-list order. + /// + /// is the search, which has no predicate, or + /// is the request (an + /// , as for a value type that is not a defined value). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the search failed with + /// . + /// + /// + /// The search observed the cancellation of . + /// + /// The search failed with any other failure kind. public ImmutableArray Find(MemoryRecordSearch search, MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default); /// Gets one record by its current zero-based address-list index. - public bool TryGetRecord(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + /// The zero-based position of the record in the address list. + /// The copied record on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// when a record was found at and copied. + /// An index is positional and changes when records are added, removed or moved; prefer an identifier. + /// is negative. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryGetRecordAt(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Gets one record by its stable Cheat Engine identifier. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// The copied record on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// when the record was found and copied. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Gets one record by index or throws when it is unavailable. - public MemoryRecordSnapshot GetRecord(int index, CancellationToken cancellationToken = default); + /// Gets one record by its current zero-based address-list index or throws when it is unavailable. + /// The zero-based position of the record in the address list. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// The copied record. + /// is negative. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// + /// + /// The read observed the cancellation of . + /// + /// + /// The read failed with any other failure kind, included. + /// + public MemoryRecordSnapshot GetRecordAt(int index, CancellationToken cancellationToken = default); /// Gets one record by identifier or throws when it is unavailable. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// The copied record. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// , for example for an identifier handed out before the last + /// trusted table load. + /// + /// + /// The read observed the cancellation of . + /// + /// + /// The read failed with any other failure kind, included. + /// public MemoryRecordSnapshot GetRecord(MemoryRecordId id, CancellationToken cancellationToken = default); /// Copies the selected address-list record when Cheat Engine has one. - public bool TryGetSelected(out MemoryRecordSnapshot record, out CheatEngineFailure failure, + /// The copied selected record on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// when a record is selected and was copied. + /// The read counterpart of , which changes the selection. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryGetSelectedRecord(out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Gets the selected record or throws when Cheat Engine has no selection. - public MemoryRecordSnapshot GetSelected(CancellationToken cancellationToken = default); - - /// Selects one record by its stable identifier and returns its copied snapshot. - public bool TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + /// Observed before the read is dispatched to Cheat Engine's main thread. + /// The copied selected record. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// + /// + /// The read observed the cancellation of . + /// + /// + /// The read failed with any other failure kind, including when no record is selected. + /// + public MemoryRecordSnapshot GetSelectedRecord(CancellationToken cancellationToken = default); + + /// Selects one record by its identifier and returns its copied snapshot. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// The copied record, now selected, on success; otherwise the default value. + /// The classified failure; the default value on success. + /// + /// Observed before the selection is dispatched to Cheat Engine's main thread. + /// + /// when the record was selected and copied. + /// + /// This changes Cheat Engine's GUI selection, which the user and other plugins see: it is a host-visible mutation, + /// not a cache operation. It is refused during a trusted table load, like every mutation (see + /// ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TrySelectRecord(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Selects one record or throws when the record is unavailable. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// + /// Observed before the selection is dispatched to Cheat Engine's main thread. + /// + /// The copied record, now selected. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the selection failed with + /// . + /// + /// + /// The selection observed the cancellation of . + /// + /// The selection failed with any other failure kind. public MemoryRecordSnapshot SelectRecord(MemoryRecordId id, CancellationToken cancellationToken = default); /// Creates a memory record, assigns its defined fields, and returns a copied snapshot. + /// The fields of the new record. + /// The copied new record on success; otherwise the default value. + /// + /// The classified failure; the default value on success. A record that could not be completed is deleted once, + /// never retried. + /// + /// + /// Observed before the creation is dispatched to Cheat Engine's main thread. + /// + /// + /// when the record was created, its fields assigned and the record copied. + /// + /// Refused during a trusted table load, like every mutation (see ). + /// + /// is the definition, which has no field, or its + /// value type is not a defined value (an ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Creates a record or throws when the host rejects it. + /// The fields of the new record. + /// + /// Observed before the creation is dispatched to Cheat Engine's main thread. + /// + /// The copied new record. + /// + /// is the definition, which has no field, or its + /// value type is not a defined value (an ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the creation failed with + /// . + /// + /// + /// The creation observed the cancellation of . + /// + /// The creation failed with any other failure kind. public MemoryRecordSnapshot Create(MemoryRecordDefinition definition, CancellationToken cancellationToken = default); - /// Applies a partial update and returns a copied snapshot of the changed record. - public bool TryUpdate(MemoryRecordUpdate update, out MemoryRecordSnapshot record, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); + /// Applies a partial update to one record and returns a copied snapshot of the changed record. + /// The identifier of the record to change, first like every record-targeting operation. + /// The fields to change. + /// The copied snapshot of the changed record on success. + /// The classified failure when the method returns . + /// Observed before dispatch. + /// when every requested field was applied and the record was copied. + /// Refused during a trusted table load, like every mutation (see ). + /// + /// is the update, which changes nothing, or its value + /// type is not a defined value (an ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// + public bool TryUpdate(MemoryRecordId id, MemoryRecordUpdate update, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Updates a record or throws when the host rejects the change. - public MemoryRecordSnapshot Update(MemoryRecordUpdate update, CancellationToken cancellationToken = default); + /// Updates one record or throws when the host rejects the change. + /// The identifier of the record to change. + /// The fields to change. + /// Observed before dispatch. + /// The copied snapshot of the changed record. + /// + /// is the update, which changes nothing, or its value + /// type is not a defined value (an ). + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the update failed with + /// . + /// + /// + /// The update observed the cancellation of . + /// + /// The update failed with any other failure kind. + public MemoryRecordSnapshot Update(MemoryRecordId id, MemoryRecordUpdate update, + CancellationToken cancellationToken = default); /// Deletes one memory record from the current Cheat Engine address list. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// The classified failure; the default value on success. + /// + /// Observed before the deletion is dispatched to Cheat Engine's main thread. + /// + /// when the record was deleted. + /// + /// CheatEngine.SDK resolves the identifier in the current list and deletes the record once: a second delete of the + /// same identifier is . A delete that raised after it started is + /// with and is never + /// retried. Every mutation (, , , + /// delete, and ) is refused without changing the Address + /// List with while a trusted table load runs on Cheat Engine's + /// main thread (a script of that table calling the Client). Delete, and + /// are also refused with when + /// Cheat Engine's Lua runtime changed before the change was attempted; both refusals report + /// . + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryDelete(MemoryRecordId id, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Deletes one memory record or throws when Cheat Engine rejects the operation. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// + /// Observed before the deletion is dispatched to Cheat Engine's main thread. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the deletion failed with + /// , for example during a trusted table load. + /// + /// + /// The deletion observed the cancellation of . + /// + /// The deletion failed with any other failure kind. public void Delete(MemoryRecordId id, CancellationToken cancellationToken = default); /// Activates or deactivates one memory record and returns its copied post-change snapshot. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// + /// to activate the record; to deactivate it. + /// + /// + /// The copied post-change record on success, and when Cheat Engine left it in the other state (see the + /// remarks); otherwise the default value. + /// + /// The classified failure; the default value on success. + /// Observed before the change is dispatched to Cheat Engine's main thread. + /// + /// when the record is in the requested state, or activating asynchronously. + /// + /// + /// + /// CheatEngine.SDK reads the record's state before and after the change. A record already in the requested + /// state succeeds without calling Cheat Engine's setter. Otherwise the setter runs exactly once and is never + /// retried (an activation-failure handler that asks for a retry already repeats the change inside Cheat + /// Engine). The record is copied after the change, in the same call on Cheat Engine's main thread. + /// + /// + /// When Cheat Engine leaves the record in the other state (an activation callback, a script or the record type + /// refused the change), the result is with + /// , and + /// set to the copied post-change snapshot; partial script effects may persist. When + /// the record activates asynchronously and is still processing, the result is and the + /// snapshot's is : its + /// is not yet the final state, which a later snapshot + /// observes. When the setter raised or the post-change state cannot be read, the result is + /// with + /// and a default . A record that is not found, and the refusals described under + /// , report . + /// + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TrySetActive(MemoryRecordId id, bool isActive, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); - /// Activates or deactivates one memory record or throws when Cheat Engine rejects the change. + /// Activates or deactivates one memory record or throws when Cheat Engine does not apply the change. + /// + /// The identifier of the record, handed out by this client since the last trusted table load. + /// + /// + /// to activate the record; to deactivate it. + /// + /// Observed before the change is dispatched to Cheat Engine's main thread. + /// The copied post-change record. + /// Follows ; the thrown failure carries the same kind and host effect. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the change failed with + /// . + /// + /// + /// The change observed the cancellation of . + /// + /// + /// The change failed with any other failure kind, including a record that Cheat Engine left in the other state. + /// public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, CancellationToken cancellationToken = default); /// Moves one record under a parent, or passes to restore it to the root. + /// The identifier of the record to move. + /// The identifier of the new parent, or for the root. + /// The copied moved record on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the move is dispatched to Cheat Engine's main thread. + /// when the record was moved and copied. + /// + /// CheatEngine.SDK walks the parent chain of the requested parent before it assigns it, up to an explicit bound of + /// 4096 records. A record requested as its own parent, or moved under one of its own descendants (a cycle), is + /// , a longer chain is + /// , and an absent record or parent is + /// ; all of them report . + /// The refusals described under apply too. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Moves one record under a parent, or restores it to the root, and returns its copied post-change state. + /// The identifier of the record to move. + /// The identifier of the new parent, or for the root. + /// Observed before the move is dispatched to Cheat Engine's main thread. + /// The copied moved record. + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the move failed with + /// . + /// + /// + /// The move observed the cancellation of . + /// + /// The move failed with any other failure kind. public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? parentId, CancellationToken cancellationToken = default); /// Copies a bounded hierarchy rooted at one memory record. + /// + /// The identifier of the root record, handed out by this client since the last table load. + /// + /// The largest number of records and of levels to copy. + /// The copied hierarchy on success; otherwise the default value. + /// The classified failure; the default value on success. + /// Observed before the copy is dispatched to Cheat Engine's main thread. + /// + /// when the hierarchy was copied within the bounds of . + /// + /// + /// Each record's children are read by position, for every position below its + /// . A child that Cheat Engine does not return at a position + /// below that count is , and the message names the record + /// identifier and the position. + /// + /// + /// is the request, which allows no record and no level. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback. + /// public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Copies a bounded hierarchy or throws when its depth, cardinality, or host shape is invalid. + /// + /// The identifier of the root record, handed out by this client since the last table load. + /// + /// The largest number of records and of levels to copy. + /// Observed before the copy is dispatched to Cheat Engine's main thread. + /// The copied hierarchy. + /// + /// is the request, which allows no record and no level. + /// + /// The activation has ended. + /// + /// The activation is stopping, outside a deactivation callback, or the copy failed with + /// . + /// + /// + /// The copy observed the cancellation of . + /// + /// The copy failed with any other failure kind. public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, CancellationToken cancellationToken = default); /// Tries to load a trusted table through Cheat Engine's native table loader. + /// The trusted table file and whether it merges into or replaces the current table. + /// The classified failure; the default value on success. + /// Observed before the load is dispatched to Cheat Engine's main thread. + /// when Cheat Engine loaded the table. + /// + /// + /// A load that reached Cheat Engine, successful or not, ends the validity of every record identifier handed + /// out earlier by this client (see the interface remarks). A table with scripts may prompt or execute Lua. + /// + /// + /// A path outside the allowed roots is refused before any Cheat Engine call + /// (). CheatEngine.SDK then calls loadTable once with the + /// path unchanged: a load that raised is with + /// , since part of the table and of its scripts may have been + /// applied, and an unavailable loader is with + /// . Failure messages never contain the path. + /// + /// + /// + /// is the request, which names no file. + /// + /// The activation has ended. + /// The activation is stopping. public bool TryLoadTrustedTable(TableLoadRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Loads a trusted table or throws when policy or host execution rejects it. + /// The trusted table file and whether it merges into or replaces the current table. + /// Observed before the load is dispatched to Cheat Engine's main thread. + /// + /// is the request, which names no file. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the load failed with . + /// + /// + /// The load observed the cancellation of . + /// + /// + /// The load failed with any other failure kind, a path refused by the policy included. + /// public void LoadTrustedTable(TableLoadRequest request, CancellationToken cancellationToken = default); /// Tries to save the current table to an explicitly allowed path. + /// The trusted table file to write. + /// The classified failure; the default value on success. + /// Observed before the save is dispatched to Cheat Engine's main thread. + /// when Cheat Engine saved the table. + /// + /// A path outside the allowed roots is refused before any Cheat Engine call. A save that raised is + /// with : the file may be + /// partially written. Failure messages never contain the path. + /// + /// + /// is the request, which names no file. + /// + /// The activation has ended. + /// The activation is stopping. public bool TrySaveTable(TableSaveRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Saves the current table or throws when policy or host execution rejects it. + /// The trusted table file to write. + /// Observed before the save is dispatched to Cheat Engine's main thread. + /// + /// is the request, which names no file. + /// + /// The activation has ended. + /// + /// The activation is stopping, or the save failed with . + /// + /// + /// The save observed the cancellation of . + /// + /// + /// The save failed with any other failure kind, a path refused by the policy included. + /// public void SaveTable(TableSaveRequest request, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs index 98b57f8..37050c3 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs @@ -4,6 +4,8 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordCollectionRequest { /// Creates a bounded record materialization request. + /// The positive maximum number of record snapshots to copy. + /// is zero or negative. public MemoryRecordCollectionRequest(int maximumItems) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs index afced03..41753d4 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs @@ -5,41 +5,75 @@ namespace CheatEngine.Client.Tables; /// Copied content fields of a Cheat Engine memory record. public readonly record struct MemoryRecordContentSnapshot { + private readonly string? _addressExpression; + private readonly string? _description; + private readonly string? _value; + /// Creates copied content fields for a memory-record snapshot. + /// The record display description. + /// The record's unresolved Cheat Engine address expression. + /// The record's verbatim value text. + /// The record's Cheat Engine value type. + /// The record's Auto Assembler script, or when it has none. + /// The number of pointer offsets of the address; 0 for a plain address. + /// + /// , or is + /// . + /// + /// is negative. public MemoryRecordContentSnapshot(string description, string addressExpression, string value, - VariableType variableType) + VariableType variableType, string? script = null, int offsetCount = 0) { ArgumentNullException.ThrowIfNull(description); ArgumentNullException.ThrowIfNull(addressExpression); ArgumentNullException.ThrowIfNull(value); + ArgumentOutOfRangeException.ThrowIfNegative(offsetCount); - Description = description; - AddressExpression = addressExpression; - Value = value; + _description = description; + _addressExpression = addressExpression; + _value = value; VariableType = variableType; + Script = script; + OffsetCount = offsetCount; } /// Gets the record display description. - public string Description - { - get; - } + /// for the value. + public string Description => _description ?? string.Empty; /// Gets the record's unresolved Cheat Engine address expression. - public string AddressExpression + /// for the value. + public string AddressExpression => _addressExpression ?? string.Empty; + + /// Gets the record's verbatim value text. + /// for the value. + public string Value => _value ?? string.Empty; + + /// Gets the record's Cheat Engine value type. + public VariableType VariableType { get; } - /// Gets the record's verbatim value text. - public string Value + /// Gets the Auto Assembler script of the record (Cheat Engine's Script property). + /// + /// when Cheat Engine returned no script text, which is the case of a record that is not an + /// Auto Assembler script, or when the script could not be read: CheatEngine.SDK reports a failed read of + /// Script the same way as a record without one. therefore does not prove that an + /// Auto Assembler record has no script, and unlike the other fields a failed Script read does not fail the + /// snapshot. + /// + public string? Script { get; } - /// Gets the record's Cheat Engine value type. - public VariableType VariableType + /// Gets the number of pointer offsets of the record's address; 0 for a plain address. + public int OffsetCount { get; } + + /// Gets whether this value is the uninitialized . + internal bool IsDefault => _description is null; } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs index ecd7aff..8f72d07 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs @@ -4,15 +4,37 @@ namespace CheatEngine.Client.Tables; /// The initial managed fields for a newly created Cheat Engine memory record. +/// +/// The value has no field: throws +/// for it before the activation check and before any Cheat Engine call. +/// public readonly record struct MemoryRecordDefinition { /// Creates a memory-record definition. + /// The display description, which can be empty. + /// The non-empty Cheat Engine address expression. + /// The Cheat Engine value text, which can be empty. + /// The defined Cheat Engine value type. + /// The parent record, or for the address-list root. + /// + /// , or is + /// . + /// + /// is empty or white space. + /// + /// is not a defined value. + /// public MemoryRecordDefinition(string description, string addressExpression, string value, VariableType variableType, MemoryRecordId? parentId = null) { ArgumentNullException.ThrowIfNull(description); ArgumentException.ThrowIfNullOrWhiteSpace(addressExpression); ArgumentNullException.ThrowIfNull(value); + if (!Enum.IsDefined(variableType)) + { + throw new ArgumentOutOfRangeException(nameof(variableType), variableType, + "A memory-record definition assigns a defined value type."); + } Description = description; AddressExpression = addressExpression; diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs index 875e991..1f7fb82 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs @@ -4,6 +4,11 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordHierarchyRequest { /// Creates a bounded hierarchy materialization request. + /// The positive maximum number of snapshots, the root included. + /// The positive maximum depth; the root has depth one. + /// + /// or is zero or negative. + /// public MemoryRecordHierarchyRequest(int maximumItems, int maximumDepth) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs index 0787f9f..730080c 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs @@ -3,37 +3,28 @@ namespace CheatEngine.Client.Tables; /// A copied, recursively bounded memory-record tree. -public readonly record struct MemoryRecordHierarchySnapshot +public readonly struct MemoryRecordHierarchySnapshot { + private readonly ImmutableArray _children; + /// Creates a copied memory-record tree and normalizes unavailable child storage to empty. - public MemoryRecordHierarchySnapshot( - MemoryRecordSnapshot Record, - ImmutableArray Children) + /// The copied root record. + /// The copied child trees; a default array is stored as empty. + public MemoryRecordHierarchySnapshot(MemoryRecordSnapshot record, + ImmutableArray children) { - this.Record = Record; - this.Children = Children; + Record = record; + _children = children.IsDefault ? ImmutableArray.Empty : children; } /// Gets the copied root record. public MemoryRecordSnapshot Record { get; - init; - } - - /// Gets the copied child records; a default array is exposed as empty. - public ImmutableArray Children - { - get => field.IsDefault ? ImmutableArray.Empty : field; - init => field = value.IsDefault ? ImmutableArray.Empty : value; } - /// Deconstructs the copied root record and normalized child records. - public void Deconstruct( - out MemoryRecordSnapshot Record, - out ImmutableArray Children) - { - Record = this.Record; - Children = this.Children; - } + /// Gets the copied child records. + /// Empty for the value, never a default array. + public ImmutableArray Children => + _children.IsDefault ? ImmutableArray.Empty : _children; } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs index a21e300..65deda5 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs @@ -6,12 +6,28 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordSearch { /// Creates a search that matches all supplied predicates. + /// A non-empty description substring, or . + /// A non-empty address expression, or . + /// A defined value type, or . + /// The active state, or . + /// + /// Every predicate is , or a text predicate is empty. + /// + /// + /// is not a defined value. + /// public MemoryRecordSearch( string? descriptionContains = null, string? addressExpression = null, VariableType? variableType = null, bool? isActive = null) { + if (variableType is { } type && !Enum.IsDefined(type)) + { + throw new ArgumentOutOfRangeException(nameof(variableType), type, + "A memory-record search compares a defined value type."); + } + if (descriptionContains is { Length: 0 }) { throw new ArgumentException("A description search value must be null or non-empty.", diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs index e9ca221..463b44c 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs @@ -1,13 +1,19 @@ using CheatEngine.SDK.Engine.AddressList; -using CheatEngine.SDK.Engine.Enums; -using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Tables; /// A copied, handle-free snapshot of a Cheat Engine memory record. +/// +/// The record's fields are grouped: holds what defines the record and +/// holds its runtime state. Nothing is copied twice. +/// public readonly record struct MemoryRecordSnapshot { /// Creates a copied memory-record snapshot from grouped content and state fields. + /// The stable Cheat Engine record identifier. + /// The current zero-based address-list position. + /// The copied content fields. + /// The copied runtime state fields. /// is negative. /// is uninitialized. public MemoryRecordSnapshot( @@ -17,7 +23,7 @@ public MemoryRecordSnapshot( MemoryRecordStateSnapshot state) { ArgumentOutOfRangeException.ThrowIfNegative(index); - if (content.Description is null || content.AddressExpression is null || content.Value is null) + if (content.IsDefault) { throw new ArgumentException("Content requires non-null text fields.", nameof(content)); } @@ -51,25 +57,4 @@ public MemoryRecordStateSnapshot State { get; } - - /// Gets the record display description. - public string Description => Content.Description; - - /// Gets the record's unresolved Cheat Engine address expression. - public string AddressExpression => Content.AddressExpression; - - /// Gets the record's verbatim value text. - public string Value => Content.Value; - - /// Gets the record's Cheat Engine value type. - public VariableType VariableType => Content.VariableType; - - /// Gets the currently resolved target address when it could be obtained. - public Address? CurrentAddress => State.CurrentAddress; - - /// Gets whether Cheat Engine reports this record as active or frozen. - public bool IsActive => State.IsActive; - - /// Gets the number of immediate child records reported by Cheat Engine. - public int ChildCount => State.ChildCount; } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs index 40d7768..c933188 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs @@ -6,13 +6,22 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordStateSnapshot { /// Creates copied runtime state fields for a memory-record snapshot. - public MemoryRecordStateSnapshot(Address? currentAddress, bool isActive = false, int childCount = 0) + /// The currently resolved target address, or . + /// Whether Cheat Engine reports the record as active or frozen. + /// The number of immediate child records. + /// Whether activating the record runs asynchronously. + /// Whether an asynchronous activation of the record is still being processed. + /// is negative. + public MemoryRecordStateSnapshot(Address? currentAddress, bool isActive = false, int childCount = 0, + bool isAsync = false, bool isAsyncProcessing = false) { ArgumentOutOfRangeException.ThrowIfNegative(childCount); CurrentAddress = currentAddress; IsActive = isActive; ChildCount = childCount; + IsAsync = isAsync; + IsAsyncProcessing = isAsyncProcessing; } /// Gets the currently resolved target address when it could be obtained. @@ -32,4 +41,19 @@ public int ChildCount { get; } + + /// Gets whether activating this record runs asynchronously (Cheat Engine's Async property). + public bool IsAsync + { + get; + } + + /// + /// Gets whether an asynchronous activation of this record was still being processed when the snapshot was copied + /// (Cheat Engine's AsyncProcessing property): is then not yet its final state. + /// + public bool IsAsyncProcessing + { + get; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs index 2959c0e..d1abb69 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs @@ -4,16 +4,37 @@ namespace CheatEngine.Client.Tables; /// A partial change set for an existing Cheat Engine memory record. +/// +/// The record it changes is the passed to , +/// first like every record-targeting operation. The value changes nothing, and +/// throws for it before the activation check +/// and before any Cheat Engine call. +/// public readonly record struct MemoryRecordUpdate { /// Creates a memory-record update containing at least one changed field. + /// The replacement description, or to keep it. + /// A non-empty replacement address expression, or . + /// The replacement value text, or to keep it. + /// The replacement value type, or to keep it. + /// + /// Every field is , or is empty. + /// + /// + /// is not a defined value. + /// public MemoryRecordUpdate( - MemoryRecordId id, string? description = null, string? addressExpression = null, string? value = null, VariableType? variableType = null) { + if (variableType is { } type && !Enum.IsDefined(type)) + { + throw new ArgumentOutOfRangeException(nameof(variableType), type, + "A memory-record update assigns a defined value type."); + } + if (description is null && addressExpression is null && value is null && variableType is null) { throw new ArgumentException("A memory-record update must change at least one field.", nameof(description)); @@ -24,19 +45,12 @@ public MemoryRecordUpdate( throw new ArgumentException("An address expression must be null or non-empty.", nameof(addressExpression)); } - Id = id; Description = description; AddressExpression = addressExpression; Value = value; VariableType = variableType; } - /// Gets the identifier of the record to change. - public MemoryRecordId Id - { - get; - } - /// Gets the optional replacement description. public string? Description { diff --git a/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs index 3b7192b..1b9b854 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs @@ -1,4 +1,26 @@ namespace CheatEngine.Client.Tables; /// Options for loading one explicitly trusted Cheat Engine table. -public readonly record struct TableLoadRequest(TrustedTableFile File, bool Merge = false); +public readonly record struct TableLoadRequest +{ + /// Creates the options of one trusted table load. + /// The table file, already admitted by the activation's allowed table roots. + /// Whether the table is merged into the current Address List instead of replacing it. + public TableLoadRequest(TrustedTableFile file, bool merge = false) + { + File = file; + Merge = merge; + } + + /// Gets the table file to load. + public TrustedTableFile File + { + get; + } + + /// Gets whether the table is merged into the current Address List instead of replacing it. + public bool Merge + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs index 3e0d459..d9d374f 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs @@ -1,4 +1,18 @@ namespace CheatEngine.Client.Tables; /// Options for saving the current Cheat Engine table without advanced signing or protection. -public readonly record struct TableSaveRequest(TrustedTableFile File); +public readonly record struct TableSaveRequest +{ + /// Creates the options of one table save. + /// The destination file, already admitted by the activation's allowed table roots. + public TableSaveRequest(TrustedTableFile file) + { + File = file; + } + + /// Gets the destination file. + public TrustedTableFile File + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs b/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs index b90aa54..133bed4 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs @@ -4,6 +4,11 @@ namespace CheatEngine.Client.Tables; public readonly record struct TrustedTableFile { /// Creates a trusted table path. Policy validation is still performed by the configured table client. + /// The absolute path of the table file; it is normalized. + /// is . + /// + /// is empty, white space or not fully qualified. + /// public TrustedTableFile(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); diff --git a/libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs b/libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs deleted file mode 100644 index 11045c5..0000000 --- a/libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Timers; - -/// Registers Client-owned recurring timers for the current activation. -public interface ITimerClient -{ - /// Tries to register a timer and its bounded copied tick stream. - public bool TryRegister( - TimerRequest request, - TimerHandler handler, - EventStreamOptions streamOptions, - [NotNullWhen(true)] out ITimerLease? lease, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default); - - /// Registers a timer or throws when the capability is unavailable or registration fails. - public ITimerLease Register( - TimerRequest request, - TimerHandler handler, - EventStreamOptions streamOptions, - CancellationToken cancellationToken = default); -} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs b/libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs deleted file mode 100644 index bb70118..0000000 --- a/libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Timers; - -/// Owns one Client timer registration and its bounded copied tick stream. -public interface ITimerLease : IEventStreamLease -{ - /// Gets the request used to create this timer. - public TimerRequest Request - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs b/libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs deleted file mode 100644 index df79112..0000000 --- a/libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CheatEngine.Client.Timers; - -/// Handles one copied timer tick synchronously without blocking the host callback thread. -public delegate void TimerHandler(TimerTick tick); diff --git a/libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs b/libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs deleted file mode 100644 index d2cc5a6..0000000 --- a/libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace CheatEngine.Client.Timers; - -/// Describes a recurring Client timer with an explicit positive interval. -public readonly record struct TimerRequest -{ - /// Creates a recurring timer request. - /// is not strictly positive. - public TimerRequest(TimeSpan interval) - { - if (interval <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(interval), "A timer interval must be strictly positive."); - } - - Interval = interval; - } - - /// Gets the strictly positive timer interval. - public TimeSpan Interval - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs b/libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs deleted file mode 100644 index 022cd8b..0000000 --- a/libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace CheatEngine.Client.Timers; - -/// Contains a copied timer tick. -public readonly record struct TimerTick -{ - /// Creates a copied timer tick. - /// is negative. - public TimerTick(long sequence, DateTimeOffset occurredAt) - { - ArgumentOutOfRangeException.ThrowIfNegative(sequence); - Sequence = sequence; - OccurredAt = occurredAt; - } - - /// Gets the non-negative sequence number within this timer registration. - public long Sequence - { - get; - } - - /// Gets the copied tick timestamp. - public DateTimeOffset OccurredAt - { - get; - } -} diff --git a/libs/CheatEngine.Client.Abstractions/packages.lock.json b/libs/CheatEngine.Client.Abstractions/packages.lock.json index a256510..98e9397 100644 --- a/libs/CheatEngine.Client.Abstractions/packages.lock.json +++ b/libs/CheatEngine.Client.Abstractions/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "CheatEngine.SDK": { "type": "Direct", - "requested": "[1.0.0, 2.0.0)", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, 3.0.0)", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", @@ -20,35 +20,18 @@ "resolved": "10.0.12", "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, - "Microsoft.SourceLink.GitHub": { + "Microsoft.Sbom.Targets": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } + "requested": "[4.1.13, )", + "resolved": "4.1.13", + "contentHash": "l9NiCqVmBBY06Lrxv61xWtiLvU1feto6j7QmMsaARBopO+QLTFDFLEIbYe8pYGjk1INJ2eWE/GGjToqQpokYAw==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + "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.Client.Core/CheatEngine.Client.Core.csproj b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj index cb7032f..186362c 100644 --- a/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj +++ b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj @@ -4,16 +4,76 @@ true false + The CheatEngine.SDK-facing implementation of the CheatEngine.Client contracts for an enabled, in-process Cheat Engine plugin. Composed through dependency injection; plugins do not call it directly. + + $(NoWarn);CECLIENT5001;CECLIENT5002;CECLIENT5003;CECLIENT5004 - + + + + + <_CheatEngineClientSdkLockFile>$(MSBuildThisFileDirectory)packages.lock.json + <_CheatEngineClientSdkLockJson Condition="Exists('$(_CheatEngineClientSdkLockFile)')">$([System.IO.File]::ReadAllText('$(_CheatEngineClientSdkLockFile)')) + <_CheatEngineClientLockedSdkVersion Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_CheatEngineClientSdkLockJson)', '"CheatEngine\.SDK"\s*:\s*\{[^}]*?"resolved"\s*:\s*"[0-9A-Za-z.+-]+"'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_CheatEngineClientSdkLockJson)', '(?s)^.*?"CheatEngine\.SDK"\s*:\s*\{[^}]*?"resolved"\s*:\s*"([0-9A-Za-z.+-]+)".*$', '$1')) + <_CheatEngineClientLockedSdkContentHash Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_CheatEngineClientSdkLockJson)', '"CheatEngine\.SDK"\s*:\s*\{[^}]*?"contentHash"\s*:\s*"[A-Za-z0-9+/=]+"'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_CheatEngineClientSdkLockJson)', '(?s)^.*?"CheatEngine\.SDK"\s*:\s*\{[^}]*?"contentHash"\s*:\s*"([A-Za-z0-9+/=]+)".*$', '$1')) + <_CheatEngineClientRestoredSdkDirectory Condition="'$(NuGetPackageRoot)' != '' and '$(_CheatEngineClientLockedSdkVersion)' != ''">$([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)'))cheatengine.sdk/$(_CheatEngineClientLockedSdkVersion.ToLowerInvariant())/ + <_CheatEngineClientRestoredSdkNuspec Condition="'$(_CheatEngineClientRestoredSdkDirectory)' != '' and Exists('$(_CheatEngineClientRestoredSdkDirectory)cheatengine.sdk.nuspec')">$([System.IO.File]::ReadAllText('$(_CheatEngineClientRestoredSdkDirectory)cheatengine.sdk.nuspec')) + <_CheatEngineClientRestoredSdkMetadata Condition="'$(_CheatEngineClientRestoredSdkDirectory)' != '' and Exists('$(_CheatEngineClientRestoredSdkDirectory).nupkg.metadata')">$([System.IO.File]::ReadAllText('$(_CheatEngineClientRestoredSdkDirectory).nupkg.metadata')) + <_CheatEngineClientRestoredSdkContentHash Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_CheatEngineClientRestoredSdkMetadata)', '"contentHash"\s*:\s*"[A-Za-z0-9+/=]+"'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_CheatEngineClientRestoredSdkMetadata)', '(?s)^.*?"contentHash"\s*:\s*"([A-Za-z0-9+/=]+)".*$', '$1')) + <_CheatEngineClientRestoredSdkCommit Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_CheatEngineClientRestoredSdkNuspec)', '<repository\b[^>]*?\bcommit="[0-9a-f]{40}"'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_CheatEngineClientRestoredSdkNuspec)', '(?s)^.*?<repository\b[^>]*?\bcommit="([0-9a-f]{40})".*$', '$1')) + + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientSdkLockJson)' == ''">the lock file $(_CheatEngineClientSdkLockFile) does not exist + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientConsumedSdkProblem)' == '' and '$(_CheatEngineClientLockedSdkVersion)' == ''">the lock file $(_CheatEngineClientSdkLockFile) has no resolved CheatEngine.SDK entry + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientConsumedSdkProblem)' == '' and '$(_CheatEngineClientLockedSdkVersion)' != '$(CheatEngineSdkVersion)'">the lock file resolves CheatEngine.SDK $(_CheatEngineClientLockedSdkVersion), not the pin $(CheatEngineSdkVersion) of eng/CheatEngineSdk.props + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientConsumedSdkProblem)' == '' and '$(_CheatEngineClientLockedSdkContentHash)' == ''">the CheatEngine.SDK entry of the lock file has no content hash + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientConsumedSdkProblem)' == '' and ('$(_CheatEngineClientRestoredSdkNuspec)' == '' or '$(_CheatEngineClientRestoredSdkMetadata)' == '')">the restored CheatEngine.SDK $(_CheatEngineClientLockedSdkVersion) package was not found under the NuGet package root '$(NuGetPackageRoot)' (restore the project first) + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientConsumedSdkProblem)' == '' and '$(_CheatEngineClientRestoredSdkContentHash)' != '$(_CheatEngineClientLockedSdkContentHash)'">the restored CheatEngine.SDK $(_CheatEngineClientLockedSdkVersion) package has content hash '$(_CheatEngineClientRestoredSdkContentHash)', not the lock value '$(_CheatEngineClientLockedSdkContentHash)' + <_CheatEngineClientConsumedSdkProblem Condition="'$(_CheatEngineClientConsumedSdkProblem)' == '' and '$(_CheatEngineClientRestoredSdkCommit)' == ''">the restored CheatEngine.SDK $(_CheatEngineClientLockedSdkVersion) package declares no repository commit in its nuspec + <_CheatEngineClientEmbedConsumedSdk Condition="'$(_CheatEngineClientConsumedSdkProblem)' == ''">true + + + + + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Core/CheatEngineClient.cs b/libs/CheatEngine.Client.Core/CheatEngineClient.cs index d7aebc0..bf9eb43 100644 --- a/libs/CheatEngine.Client.Core/CheatEngineClient.cs +++ b/libs/CheatEngine.Client.Core/CheatEngineClient.cs @@ -1,21 +1,14 @@ using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; namespace CheatEngine.Client.Core; @@ -37,19 +30,12 @@ internal CheatEngineClient( Processes = domainServices.Processes ?? throw new ArgumentNullException(nameof(domainServices)); Memory = domainServices.Memory ?? throw new ArgumentNullException(nameof(domainServices)); Patterns = domainServices.Patterns ?? throw new ArgumentNullException(nameof(domainServices)); - Scans = domainServices.Scans ?? throw new ArgumentNullException(nameof(domainServices)); + ValueScans = domainServices.ValueScans ?? throw new ArgumentNullException(nameof(domainServices)); Inspection = domainServices.Inspection ?? throw new ArgumentNullException(nameof(domainServices)); Tables = domainServices.Tables ?? throw new ArgumentNullException(nameof(domainServices)); Lua = domainServices.Lua ?? throw new ArgumentNullException(nameof(domainServices)); Allocations = domainServices.Allocations ?? throw new ArgumentNullException(nameof(domainServices)); Assembly = domainServices.Assembly ?? throw new ArgumentNullException(nameof(domainServices)); - RemoteExecution = domainServices.RemoteExecution ?? throw new ArgumentNullException(nameof(domainServices)); - Debugger = domainServices.Debugger ?? throw new ArgumentNullException(nameof(domainServices)); - Hotkeys = domainServices.Hotkeys ?? throw new ArgumentNullException(nameof(domainServices)); - Timers = domainServices.Timers ?? throw new ArgumentNullException(nameof(domainServices)); - Speed = domainServices.Speed ?? throw new ArgumentNullException(nameof(domainServices)); - Hashing = domainServices.Hashing ?? throw new ArgumentNullException(nameof(domainServices)); - Dbvm = domainServices.Dbvm ?? throw new ArgumentNullException(nameof(domainServices)); } public long Epoch => _lifetime.Epoch; @@ -80,7 +66,7 @@ public IPatternScanner Patterns get; } - public IValueScanner Scans + public IValueScanner ValueScans { get; } @@ -109,39 +95,4 @@ public IAssemblyClient Assembly { get; } - - public IRemoteExecutionClient RemoteExecution - { - get; - } - - public IDebuggerClient Debugger - { - get; - } - - public IHotkeyClient Hotkeys - { - get; - } - - public ITimerClient Timers - { - get; - } - - public ISpeedClient Speed - { - get; - } - - public IHashingClient Hashing - { - get; - } - - public IDbvmClient Dbvm - { - get; - } } diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs index c7358f9..c9ceed4 100644 --- a/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs +++ b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs @@ -1,18 +1,11 @@ -using CheatEngine.Client.Allocations; +using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; namespace CheatEngine.Client.Core; @@ -21,16 +14,9 @@ internal sealed record CheatEngineClientDomainServices( IProcessClient Processes, IMemoryClient Memory, IPatternScanner Patterns, - IValueScanner Scans, + IValueScanner ValueScans, IInspectionClient Inspection, ITableClient Tables, ILuaClient Lua, IAllocationClient Allocations, - IAssemblyClient Assembly, - IRemoteExecutionClient RemoteExecution, - IDebuggerClient Debugger, - IHotkeyClient Hotkeys, - ITimerClient Timers, - ISpeedClient Speed, - IHashingClient Hashing, - IDbvmClient Dbvm); + IAssemblyClient Assembly); diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs index 18bdb18..00645b0 100644 --- a/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs +++ b/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs @@ -1,4 +1,4 @@ -using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Dispatching; using CheatEngine.Client.Runtime; namespace CheatEngine.Client.Core; diff --git a/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs index 710fa52..332e06b 100644 --- a/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs +++ b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs @@ -1,4 +1,4 @@ -namespace CheatEngine.Client.Core.Dispatching; +namespace CheatEngine.Client.Core.Dispatching; internal interface IMainThreadInvoker { diff --git a/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs b/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs index dd31ef8..1df55b3 100644 --- a/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs +++ b/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs @@ -1,3 +1,3 @@ -namespace CheatEngine.Client.Core.Dispatching; +namespace CheatEngine.Client.Core.Dispatching; internal readonly record struct MainThreadInvocationResult(T Result, Exception? Exception); diff --git a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs index eb0a510..e56451d 100644 --- a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs +++ b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs @@ -12,9 +12,8 @@ namespace CheatEngine.Client.Core.Dispatching; /// Adapts the SDK's synchronous main-thread dispatcher without retaining Lua state. internal sealed class SdkMainThreadDispatcher : ICheatEngineDispatcher, IStatefulCheatEngineDispatcher { - private const string _invokeOperation = "Dispatcher.Invoke"; + private const string InvokeOperation = "Dispatcher.Invoke"; - private readonly CoreLifetime _lifetime; private readonly IMainThreadInvoker _mainThread; internal SdkMainThreadDispatcher(CoreLifetime lifetime) @@ -24,20 +23,26 @@ internal SdkMainThreadDispatcher(CoreLifetime lifetime) internal SdkMainThreadDispatcher(CoreLifetime lifetime, IMainThreadInvoker mainThread) { - _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + Lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _mainThread = mainThread ?? throw new ArgumentNullException(nameof(mainThread)); } - public bool IsMainThread => _lifetime.CanDispatch && MainThread.IsMainThread; + public bool IsMainThread => Lifetime.CanDispatch && MainThread.IsMainThread; + + /// Gets the activation lifetime that admits this dispatcher's work. + internal CoreLifetime Lifetime + { + get; + } public bool TryInvoke(Action callback, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(callback); - _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + Lifetime.ThrowIfDispatchRefused(InvokeOperation); if (cancellationToken.IsCancellationRequested) { - failure = CoreFailureFactory.Cancelled(_invokeOperation); + failure = CoreFailureFactory.Cancelled(InvokeOperation); return false; } @@ -50,14 +55,15 @@ public bool TryInvoke(Action callback, out CheatEngineFailure failure, { throw; } - catch (Exception exception) when (!_lifetime.IsActivationCurrent) + catch (Exception exception) when (!Lifetime.IsActivationCurrent) { - throw new CheatEngineActivationExpiredException(_invokeOperation, - "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + throw ClientExceptions.ActivationExpired(InvokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception, + CheatEngineHostEffect.Unknown); } catch (Exception exception) { - failure = CoreFailureFactory.FromException(_invokeOperation, exception); + failure = SdkBoundary.Classify(InvokeOperation, exception, CheatEngineHostEffect.Unknown); return false; } @@ -74,11 +80,11 @@ public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(callback); - _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + Lifetime.ThrowIfDispatchRefused(InvokeOperation); if (cancellationToken.IsCancellationRequested) { result = default; - failure = CoreFailureFactory.Cancelled(_invokeOperation); + failure = CoreFailureFactory.Cancelled(InvokeOperation); return false; } @@ -91,16 +97,17 @@ public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, { throw; } - catch (Exception exception) when (!_lifetime.IsActivationCurrent) + catch (Exception exception) when (!Lifetime.IsActivationCurrent) { result = default; - throw new CheatEngineActivationExpiredException(_invokeOperation, - "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + throw ClientExceptions.ActivationExpired(InvokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception, + CheatEngineHostEffect.Unknown); } catch (Exception exception) { result = default; - failure = CoreFailureFactory.FromException(_invokeOperation, exception); + failure = SdkBoundary.Classify(InvokeOperation, exception, CheatEngineHostEffect.Unknown); return false; } @@ -118,7 +125,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - _ = ThrowFailure(failure); + _ = ThrowFailure(failure, cancellationToken); } } @@ -129,7 +136,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - return ThrowFailure(failure); + return ThrowFailure(failure, cancellationToken); } public bool TryInvoke(TState state, Func callback, @@ -137,11 +144,11 @@ public bool TryInvoke(TState state, Func callb CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(callback); - _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + Lifetime.ThrowIfDispatchRefused(InvokeOperation); if (cancellationToken.IsCancellationRequested) { result = default; - failure = CoreFailureFactory.Cancelled(_invokeOperation); + failure = CoreFailureFactory.Cancelled(InvokeOperation); return false; } @@ -155,16 +162,17 @@ public bool TryInvoke(TState state, Func callb result = default; throw; } - catch (Exception exception) when (!_lifetime.IsActivationCurrent) + catch (Exception exception) when (!Lifetime.IsActivationCurrent) { result = default; - throw new CheatEngineActivationExpiredException(_invokeOperation, - "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + throw ClientExceptions.ActivationExpired(InvokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception, + CheatEngineHostEffect.Unknown); } catch (Exception exception) { result = default; - failure = CoreFailureFactory.FromException(_invokeOperation, exception); + failure = SdkBoundary.Classify(InvokeOperation, exception, CheatEngineHostEffect.Unknown); return false; } @@ -183,10 +191,10 @@ public bool TryInvoke(TState state, Action callback, out CheatEn CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(callback); - _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + Lifetime.ThrowIfDispatchRefused(InvokeOperation); if (cancellationToken.IsCancellationRequested) { - failure = CoreFailureFactory.Cancelled(_invokeOperation); + failure = CoreFailureFactory.Cancelled(InvokeOperation); return false; } @@ -199,14 +207,15 @@ public bool TryInvoke(TState state, Action callback, out CheatEn { throw; } - catch (Exception exception) when (!_lifetime.IsActivationCurrent) + catch (Exception exception) when (!Lifetime.IsActivationCurrent) { - throw new CheatEngineActivationExpiredException(_invokeOperation, - "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + throw ClientExceptions.ActivationExpired(InvokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception, + CheatEngineHostEffect.Unknown); } catch (Exception exception) { - failure = CoreFailureFactory.FromException(_invokeOperation, exception); + failure = SdkBoundary.Classify(InvokeOperation, exception, CheatEngineHostEffect.Unknown); return false; } @@ -219,9 +228,9 @@ public bool TryInvoke(TState state, Action callback, out CheatEn return true; } - private static T ThrowFailure(CheatEngineFailure failure) + private static T ThrowFailure(CheatEngineFailure failure, CancellationToken cancellationToken) { - failure.Throw(); + failure.Throw(cancellationToken); throw new UnreachableException(); } diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/AllocationClient.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/AllocationClient.cs new file mode 100644 index 0000000..41b1639 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/AllocationClient.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Allocation; + +namespace CheatEngine.Client.Core.Domains.Allocations; + +/// Allocates target memory through CheatEngine.SDK's allocator, on Cheat Engine's main thread. +/// +/// +/// A published allocation is registered with the activation and with the target selection it was made in, in the +/// same main-thread callback that made it, so no path leaves it without an owner: a registration that fails, and +/// a cancellation observed after the allocation, release it at once. An ended or stopping activation is refused +/// in that callback before Cheat Engine allocates, since no lease could own the allocation; a registration refused +/// after the allocation reports the release and the address (). +/// +/// +/// The target selection is the one of the process incarnation that CheatEngine.SDK bound the allocation to +/// (), not the last selection the Client observed: a process selected in Cheat +/// Engine's own window since then advances the epoch before the lease is registered, so the next observation never +/// releases an allocation whose own process is still selected. +/// +/// +internal sealed class AllocationClient : IAllocationClient +{ + /// The public operation name of an allocation. + internal const string AllocateOperation = "Allocations.Allocate"; + + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly IAllocationPort _port; + private readonly ITargetSelectionBinder _selection; + + /// Creates the allocation client of an activation. + /// The activation dispatcher. + /// The owner of the observed target selection, the activation's process client. + /// The allocator; CheatEngine.SDK's when omitted. + internal AllocationClient(SdkMainThreadDispatcher dispatcher, ITargetSelectionBinder selection, + IAllocationPort? port = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _port = port ?? SdkAllocationPort.Instance; + } + + public bool TryAllocate(AllocationRequest request, [NotNullWhen(true)] out ITargetMemoryLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + lease = null; + // Arguments first. Creating a lease-owned resource is then admitted only while the activation is active, never + // from the cleanup scope: an ended or stopping activation throws before a cancellation is reported. + TargetAllocationRequest sdkRequest = AllocationMapping.CreateRequest(request); + _dispatcher.Lifetime.ThrowIfInactive(AllocateOperation); + if (cancellationToken.IsCancellationRequested) + { + failure = CancellationMapping.BeforeNativeCall(AllocateOperation); + return false; + } + + if (!_dispatcher.TryInvoke(() => AllocateOnMainThread(request, sdkRequest, cancellationToken), + out AllocateOutcome outcome, out failure, cancellationToken)) + { + return false; + } + + _selection.ReportBinding(outcome.Binding, AllocateOperation); + lease = outcome.Lease; + failure = outcome.Failure; + return lease is not null; + } + + public ITargetMemoryLease Allocate(AllocationRequest request, CancellationToken cancellationToken = default) + { + if (TryAllocate(request, out ITargetMemoryLease? lease, out CheatEngineFailure failure, cancellationToken)) + { + return lease; + } + + failure.Throw(cancellationToken); + throw new UnreachableException(); + } + + private AllocateOutcome AllocateOnMainThread(AllocationRequest request, TargetAllocationRequest sdkRequest, + CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return new AllocateOutcome(null, CancellationMapping.BeforeNativeCall(AllocateOperation)); + } + + CoreLifetime lifetime = _dispatcher.Lifetime; + // No lease can be registered once the activation stops or ends (a deactivation cleanup scope included): refuse + // before Cheat Engine allocates anything that no lease could own. + lifetime.ThrowIfInactive(AllocateOperation); + AllocationAttempt attempt; + IAllocatedRegionHandle? region; + try + { + attempt = _port.TryAllocate(in sdkRequest, out region); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + // The SDK reports every expected result as an outcome; a fault means that how far the call got is not known. + return new AllocateOutcome(null, + SdkBoundary.Translate(AllocateOperation, fault, CheatEngineHostEffect.Unknown, lifetime)); + } + + if (region is null) + { + return new AllocateOutcome(null, + AllocationMapping.FromUnpublishedAllocation(attempt, request.Size, AllocateOperation)); + } + + if (cancellationToken.IsCancellationRequested) + { + // Nothing is published after a late cancellation: the new allocation is released at once. + LeaseReleaseOutcome released = SdkReleaseOutcomes.FromTarget(region.Release()); + return new AllocateOutcome(null, + AllocationMapping.CancelledAfterAllocation(released, attempt.Address, request.Size, AllocateOperation)); + } + + TargetSelectionBinding binding = default; + try + { + binding = _selection.BindOwner(region.TargetIncarnation, AllocateOperation); + TargetMemoryLease lease = new(_dispatcher, region, attempt.Address, request, binding.SelectionEpoch); + lease.Register(lifetime, binding.SelectionEpoch); + return new AllocateOutcome(lease, default) + { + Binding = binding + }; + } + catch (Exception registration) + { + // The activation or the target selection ended while the allocation was made: release it here, on the main + // thread, since no registry will. + LeaseReleaseOutcome released = SdkReleaseOutcomes.FromTarget(region.Release()); + if (registration is not (CheatEngineClientException or ObjectDisposedException)) + { + throw; + } + + return new AllocateOutcome(null, LeaseRegistration.Refused(lifetime, AllocateOperation, registration, + released, AllocationMapping.Describe(attempt.Address, request.Size))) + { + Binding = binding + }; + } + } + + private readonly record struct AllocateOutcome(TargetMemoryLease? Lease, CheatEngineFailure Failure) + { + /// Gets the selection binding of a published lease, reported after the callback returned. + internal TargetSelectionBinding Binding + { + get; + init; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/AllocationMapping.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/AllocationMapping.cs new file mode 100644 index 0000000..ee30aca --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/AllocationMapping.cs @@ -0,0 +1,213 @@ +using System.Globalization; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains.Allocations; + +/// Maps every allocation outcome that CheatEngine.SDK 2.0.0 reports to the Client vocabulary. +/// +/// +/// Each mapping is total over its enum, and a value this Client version does not know fails closed +/// (, and an effect that never claims that nothing +/// remains); the mapping-totality tests fail when the consumed SDK adds a value. Exceptions are classified by +/// . The effect of an allocation that published no owner comes from +/// , and a release from . +/// +/// +/// When Cheat Engine allocated but no owner could be published, the SDK makes one compensating release. A +/// compensation that is not confirmed leaves , and the +/// failure message carries the address and the size so the application can recover the memory by other means. +/// +/// +internal static class AllocationMapping +{ + /// Validates a Client request, as its constructor does, and converts it to the SDK request. + /// The Client request. + /// The SDK request, with an explicit protection. + /// + /// The size is not positive (the request), the protection is not a value this Client + /// version defines, or the preferred address is the null address. + /// + internal static TargetAllocationRequest CreateRequest(AllocationRequest request) + { + if (request.Size <= 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.Size, + "An allocation request requires a positive size; the default request has none."); + } + + if (!TryGetSdkProtection(request.Protection, out MemoryProtection protection)) + { + throw new ArgumentOutOfRangeException(nameof(request), request.Protection, + "The allocation protection is not a value this Client version defines."); + } + + if (request.PreferredAddress is { IsZero: true }) + { + throw new ArgumentOutOfRangeException(nameof(request), request.PreferredAddress, + "A preferred allocation address must be nonzero."); + } + + return new TargetAllocationRequest(new TargetAllocationSize(request.Size), request.PreferredAddress, + protection); + } + + /// Maps a Client protection to the Cheat Engine PAGE_* value passed to allocateMemory. + /// The Client protection. + /// The page protection, or for an unknown value. + /// for a defined protection. + internal static bool TryGetSdkProtection(AllocationProtection protection, out MemoryProtection sdkProtection) + { + sdkProtection = protection switch + { + AllocationProtection.ReadWrite => MemoryProtection.ReadWrite, + AllocationProtection.ExecuteReadWrite => MemoryProtection.ExecuteReadWrite, + _ => MemoryProtection.None + }; + return sdkProtection != MemoryProtection.None; + } + + /// Maps the category of an allocateMemory call that published no allocation. + /// The category reported by CheatEngine.SDK. + /// + /// The failure kind; for a success or an unspecified + /// category (neither publishes an owner without breaking the SDK contract) and for an unrecognized value. + /// + internal static CheatEngineFailureKind ToFailureKind(TargetMemoryOperationOutcomeKind kind) + { + return kind switch + { + TargetMemoryOperationOutcomeKind.ExpectedFailure => CheatEngineFailureKind.OperationRejected, + TargetMemoryOperationOutcomeKind.GlobalUnavailable or TargetMemoryOperationOutcomeKind.CapabilityUnavailable => + CheatEngineFailureKind.CapabilityUnavailable, + TargetMemoryOperationOutcomeKind.ProtectedLuaFailure => CheatEngineFailureKind.LuaError, + TargetMemoryOperationOutcomeKind.BindingFailure => CheatEngineFailureKind.BindingError, + TargetMemoryOperationOutcomeKind.MarshallingFailure => CheatEngineFailureKind.InvalidHostResult, + TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable => CheatEngineFailureKind.TargetIdentityUnavailable, + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch => CheatEngineFailureKind.TargetChanged, + TargetMemoryOperationOutcomeKind.Unspecified or TargetMemoryOperationOutcomeKind.Succeeded => + CheatEngineFailureKind.IndeterminateHostResult, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Maps an allocation for which CheatEngine.SDK published no owner to its failure. + /// The copied SDK outcome. + /// The requested size, reported with an allocation that may remain. + /// The public Client operation name. + /// The classified failure. + internal static CheatEngineFailure FromUnpublishedAllocation(AllocationAttempt attempt, long size, string operation) + { + if (attempt.Compensation is { } compensation) + { + return FromCompensation(compensation, attempt.Address, size, operation); + } + + if (attempt.Effect == EngineEffectState.Applied) + { + // Cheat Engine allocated, and the SDK published neither an owner nor a compensation: a contract break that + // may leave the allocation in the target. + return Failure(CheatEngineFailureKind.IndeterminateHostResult, operation, + CheatEngineHostEffect.CleanupUnconfirmed, + $"Cheat Engine allocated {Describe(attempt.Address, size)} but CheatEngine.SDK published no owner and " + + "released nothing: the allocation may remain in the target."); + } + + CheatEngineFailureKind kind = ToFailureKind(attempt.Kind); + string message = kind switch + { + CheatEngineFailureKind.OperationRejected => "Cheat Engine's allocateMemory returned nil: nothing was allocated.", + CheatEngineFailureKind.CapabilityUnavailable => + "Cheat Engine's allocateMemory is unavailable: nothing was allocated.", + CheatEngineFailureKind.LuaError => "Cheat Engine's allocateMemory raised a Lua error.", + CheatEngineFailureKind.BindingError => "The CheatEngine.SDK allocation binding could not uphold its contract.", + CheatEngineFailureKind.InvalidHostResult => + "Cheat Engine's allocateMemory returned a value that is neither a target address nor nil.", + CheatEngineFailureKind.TargetIdentityUnavailable => + "The identity of Cheat Engine's selected target could not be established, so nothing was allocated.", + CheatEngineFailureKind.TargetChanged => "Cheat Engine's selected target changed, so nothing was allocated.", + _ => "CheatEngine.SDK reported no recognized allocation outcome and published no owner." + }; + if (!attempt.Address.IsZero) + { + message += $" Cheat Engine still returned {Describe(attempt.Address, size)}: an allocation may remain in " + + "the target."; + } + + return Failure(kind, operation, HostEffectMapping.FromSdk(attempt.Effect), message); + } + + /// Maps the compensating release of an allocation that Cheat Engine made but no owner took. + /// The status of the SDK's one compensating release. + /// The address Cheat Engine returned. + /// The requested size. + /// The public Client operation name. + /// + /// A failure whose kind is the reason of a refused release (, + /// , , + /// ) or ; + /// its effect is when the release was confirmed, otherwise + /// with the address in the message. + /// + internal static CheatEngineFailure FromCompensation(TargetReleaseStatus status, Address address, long size, + string operation) + { + LeaseReleaseOutcome released = SdkReleaseOutcomes.FromTarget(status); + CheatEngineFailureKind kind = released.Kind switch + { + LeaseReleaseKind.RefusedTargetNotAttached => CheatEngineFailureKind.TargetNotAttached, + LeaseReleaseKind.RefusedTargetChanged => CheatEngineFailureKind.TargetChanged, + LeaseReleaseKind.RefusedTargetIdentityUnavailable => CheatEngineFailureKind.TargetIdentityUnavailable, + LeaseReleaseKind.RefusedRuntimeChanged => CheatEngineFailureKind.RuntimeChanged, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + return released.IsComplete + ? Failure(kind, operation, CheatEngineHostEffect.Completed, + "Cheat Engine allocated the memory but CheatEngine.SDK could not publish its owner; the allocation was " + + "released at once.") + : Failure(kind, operation, CheatEngineHostEffect.CleanupUnconfirmed, + $"Cheat Engine allocated {Describe(address, size)} but CheatEngine.SDK could not publish its owner, and " + + $"releasing it ended with {released.Kind}: the allocation may remain in the target."); + } + + /// Maps a cancellation observed after Cheat Engine allocated, once the allocation was released. + /// The outcome of the release made because of the cancellation. + /// The allocated address. + /// The requested size. + /// The public Client operation name. + /// + /// A failure: when the + /// release was confirmed, otherwise with the address in the + /// message. + /// + internal static CheatEngineFailure CancelledAfterAllocation(LeaseReleaseOutcome released, Address address, long size, + string operation) + { + return released.IsComplete + ? CancellationMapping.AfterNativeCall(operation, + "The operation was cancelled after Cheat Engine allocated the memory; the allocation was released and no " + + "lease was published.") + : Failure(CheatEngineFailureKind.Cancelled, operation, CheatEngineHostEffect.CleanupUnconfirmed, + $"The operation was cancelled after Cheat Engine allocated {Describe(address, size)}, and releasing it " + + $"ended with {released.Kind}: the allocation may remain in the target."); + } + + /// Describes an allocation for a failure message: its size and hexadecimal target address. + internal static string Describe(Address address, long size) + { + return string.Create(CultureInfo.InvariantCulture, $"{size} bytes at 0x{address.Value:X}"); + } + + private static CheatEngineFailure Failure(CheatEngineFailureKind kind, string operation, + CheatEngineHostEffect hostEffect, string message) + { + return new CheatEngineFailure(kind, operation, message, null, hostEffect); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/IAllocationPort.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/IAllocationPort.cs new file mode 100644 index 0000000..fd01d46 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/IAllocationPort.cs @@ -0,0 +1,51 @@ +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains.Allocations; + +/// Internal boundary that allocates target memory through CheatEngine.SDK; Core tests replace it with doubles. +/// Every member runs on Cheat Engine's main thread, inside a dispatched callback. +internal interface IAllocationPort +{ + /// Allocates memory in Cheat Engine's selected target (TargetMemoryAllocator.TryAllocate). + /// The SDK request, built and validated by the Client. + /// The owner of the allocation, only when CheatEngine.SDK published one. + /// The factual copy of the SDK outcome. + public AllocationAttempt TryAllocate(in TargetAllocationRequest request, out IAllocatedRegionHandle? region); +} + +/// Internal view of one CheatEngine.SDK AllocatedRegion: its target binding and its release only. +/// Every member runs on Cheat Engine's main thread, inside a dispatched callback. +internal interface IAllocatedRegionHandle +{ + /// Gets the process incarnation that CheatEngine.SDK bound the allocation to (TargetIncarnation). + public TargetProcessIncarnation TargetIncarnation + { + get; + } + + /// + /// Frees the allocation in the process incarnation it was made in (ReleaseWithTargetOutcome); never throws. + /// CheatEngine.SDK refuses, without any Cheat Engine call, when another target or another Lua runtime is current. + /// + /// + /// The status of the one release attempt; a later call returns the same status without any Cheat Engine call. + /// + public TargetReleaseStatus Release(); +} + +/// The copied facts of one TargetMemoryAllocator.TryAllocate call. +/// The category of the allocateMemory call. +/// How far the allocation went. +/// The address Cheat Engine returned, including when no owner was published; otherwise zero. +/// +/// The status of the one release CheatEngine.SDK attempted because Cheat Engine allocated but no owner could be +/// published; when no compensation was needed. +/// +internal readonly record struct AllocationAttempt( + TargetMemoryOperationOutcomeKind Kind, + EngineEffectState Effect, + Address Address, + TargetReleaseStatus? Compensation); diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/SdkAllocationPort.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/SdkAllocationPort.cs new file mode 100644 index 0000000..0509d52 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/SdkAllocationPort.cs @@ -0,0 +1,82 @@ +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains.Allocations; + +/// Production adapter over TargetMemoryAllocator and AllocatedRegion of CheatEngine.SDK 2.0.0. +/// +/// +/// Allocation goes through , the SDK path that binds an allocation +/// to the Lua runtime and the qualified target incarnation that made it, and that makes the one compensation +/// attempt when Cheat Engine allocated but no owner could be published. The release goes through +/// , which refuses another target or runtime without any +/// Cheat Engine call and never selects a target. +/// +/// +/// Only a hosted Cheat Engine can reach this adapter: it is excluded from the coverage metric, and Core tests +/// exercise the allocation domain through doubles. +/// +/// +internal sealed class SdkAllocationPort : IAllocationPort +{ + private readonly TargetMemoryAllocator _allocator = new(); + + private SdkAllocationPort() + { + } + + /// Gets the production adapter. + internal static SdkAllocationPort Instance + { + get; + } = new(); + + public AllocationAttempt TryAllocate(in TargetAllocationRequest request, out IAllocatedRegionHandle? region) + { + TargetAllocationAcquireOutcome outcome = _allocator.TryAllocate(request, out AllocatedRegion? created); + TargetReleaseStatus? compensation = outcome.Compensation?.Status; + if (outcome.HasOwner && created is not null) + { + region = new SdkAllocatedRegionHandle(created); + return new AllocationAttempt(outcome.Allocation.Operation.Kind, outcome.Effect, outcome.Allocation.Address, + compensation); + } + + // The SDK publishes a region only with an owner. A region that a contract break publishes anyway is released here, + // once, and its release is reported as the compensation, rather than leaving the allocation without an owner. + if (created is not null) + { + TargetReleaseStatus released = created.ReleaseWithTargetOutcome().Status; + compensation ??= released; + } + + region = null; + return new AllocationAttempt(outcome.Allocation.Operation.Kind, outcome.Effect, outcome.Allocation.Address, + compensation); + } + + private sealed class SdkAllocatedRegionHandle(AllocatedRegion region) : IAllocatedRegionHandle + { + private readonly AllocatedRegion _region = region ?? throw new ArgumentNullException(nameof(region)); + + public TargetProcessIncarnation TargetIncarnation => _region.TargetIncarnation; + + public TargetReleaseStatus Release() + { + // The SDK consumes the owner on its one attempt: a later call reports that attempt again, without any call. + if (_region.IsDisposed) + { + return _region.LastReleaseOutcome.Status; + } + + try + { + return _region.ReleaseWithTargetOutcome().Status; + } + catch (ObjectDisposedException) + { + return _region.LastReleaseOutcome.Status; + } + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/TargetMemoryLease.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/TargetMemoryLease.cs new file mode 100644 index 0000000..570ebcc --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/TargetMemoryLease.cs @@ -0,0 +1,73 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains.Allocations; + +/// One target allocation: a lease over one CheatEngine.SDK AllocatedRegion. +/// +/// +/// The lease is a registered with the activation and with the target selection it +/// was made in. Its release runs ReleaseWithTargetOutcome on Cheat Engine's main thread, when the +/// application releases it, when the Client observes that Cheat Engine selected another process, or before +/// CheatEngine.SDK detaches at deactivation. The SDK frees the allocation only in the process incarnation and +/// the Lua runtime that made it; otherwise it refuses without any Cheat Engine call, and the lease never +/// selects a target to free it. The release that follows a target change is therefore refused and frees +/// nothing. +/// +/// +/// The address, size and protection are copied when the allocation is made, so they stay readable after the +/// release, which the manual recovery of a refused or unconfirmed release needs. +/// +/// +internal sealed class TargetMemoryLease : HostResourceLease, ITargetMemoryLease +{ + /// The public operation name of the release. + internal const string ReleaseOperation = "Allocations.Release"; + + private readonly IAllocatedRegionHandle _region; + + /// Creates the lease of an allocation that CheatEngine.SDK published; the caller registers it. + /// The activation dispatcher. + /// The SDK owner, which this lease owns from now on. + /// The allocated address. + /// The request the allocation was made for. + /// The target-selection epoch the allocation was made in. + internal TargetMemoryLease(SdkMainThreadDispatcher dispatcher, IAllocatedRegionHandle region, Address address, + AllocationRequest request, long selectionEpoch) + : base(ReleaseOperation, dispatcher, dispatcher?.Lifetime.Diagnostics) + { + _region = region ?? throw new ArgumentNullException(nameof(region)); + Address = address; + Size = request.Size; + Protection = request.Protection; + SelectionEpoch = selectionEpoch; + } + + public Address Address + { + get; + } + + public long Size + { + get; + } + + public AllocationProtection Protection + { + get; + } + + public long SelectionEpoch + { + get; + } + + protected override LeaseReleaseOutcome ReleaseOnMainThread() + { + return SdkReleaseOutcomes.FromTarget(_region.Release()); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs deleted file mode 100644 index 4b2eaee..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Allocations; -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.Allocations; - -/// Preserves the allocation contract while its SDK ownership factory awaits live-host validation. -internal sealed class UnavailableAllocationClient : IAllocationClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableAllocationClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryAllocate(TargetAllocationRequest request, [NotNullWhen(true)] out ITargetMemoryLease? lease, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - lease = null; - failure = UnavailableCapabilityFailure.Create(_lifetime, "Target allocations", "Allocations.Allocate", - cancellationToken); - return false; - } - - public ITargetMemoryLease Allocate(TargetAllocationRequest request, CancellationToken cancellationToken = default) - { - _ = TryAllocate(request, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/AobBoundedHostResult.cs b/libs/CheatEngine.Client.Core/Domains/AobBoundedHostResult.cs new file mode 100644 index 0000000..987cdd9 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/AobBoundedHostResult.cs @@ -0,0 +1,144 @@ +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// A copied CheatEngine.SDK AobBoundedScanResult: the outcome of one bounded, exhaustive AOB scan, its copy +/// accounting, Cheat Engine's error text and the release of its MemScan session. +/// +/// +/// The SDK result and its release outcome have internal constructors, so the port copies them into this value and +/// is testable without a host. The addresses themselves are written to the caller's +/// destination; says how many. +/// +internal readonly record struct AobBoundedHostResult +{ + /// Gets the factual outcome category. + public AobBoundedScanOutcomeKind Kind + { + get; + init; + } + + /// Gets the session factory status; when none was attempted. + public MemoryScanCreationStatus CreationStatus + { + get; + init; + } + + /// Gets the protected Lua status of . + public LuaStatus LuaStatus + { + get; + init; + } + + /// Gets the available results Cheat Engine reported, including rows outside the bounds. + public ulong HostResultCount + { + get; + init; + } + + /// Gets how many in-bounds addresses were written to the destination; zero unless the scan succeeded. + public int Written + { + get; + init; + } + + /// Gets how many rows were read. + public ulong RowsRead + { + get; + init; + } + + /// Gets how many available rows were not read. + public ulong UnreadHostRows + { + get; + init; + } + + /// Gets how many returned addresses lay below the start bound and were dropped by the SDK. + public ulong BelowStartSkipped + { + get; + init; + } + + /// Gets how many returned addresses lay at or above the stop bound and were dropped by the SDK. + public ulong AtOrAfterStopSkipped + { + get; + init; + } + + /// Gets whether the destination filled up while unread rows remained. + public bool IsMaterializationLimitReached + { + get; + init; + } + + /// Gets Cheat Engine's non-empty error text, bounded by the SDK and never parsed. + public string? HostErrorText + { + get; + init; + } + + /// Gets whether is a prefix of a longer text. + public bool IsHostErrorTextTruncated + { + get; + init; + } + + /// Gets whether reading Cheat Engine's error text failed. + public bool IsHostErrorTextUnreadable + { + get; + init; + } + + /// Gets the time from the first-scan call to the end of the successful wait; zero when the scan did not complete. + public TimeSpan HostScanElapsed + { + get; + init; + } + + /// Gets the time the SDK spent reading the count, the error text and the rows. + public TimeSpan CopyElapsed + { + get; + init; + } + + /// Gets the release status of the session's found list. + public TargetReleaseStatus FoundListRelease + { + get; + init; + } + + /// Gets the release status of the session's scanner. + public TargetReleaseStatus MemScanRelease + { + get; + init; + } + + /// Gets how a scan that may still have been running was stopped before the session was released. + public MemoryScanTerminationStatus ReleaseTermination + { + get; + init; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/AobHostOutcome.cs b/libs/CheatEngine.Client.Core/Domains/AobHostOutcome.cs new file mode 100644 index 0000000..0b75460 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/AobHostOutcome.cs @@ -0,0 +1,33 @@ +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// A copied CheatEngine.SDK AobScanOutcome and the AobScanTargetContext of the same global +/// AOBScan call. +/// +/// +/// The SDK context type has an internal constructor, so the port copies its two target observations into +/// and is testable without a host. The observations +/// are facts: they never change . +/// +/// The factual SDK outcome category. +/// The protected Lua status of a . +/// The verified host-list count of . +/// The target selection observed immediately before the AOBScan call. +/// The target selection observed immediately after the AOBScan call returned. +internal readonly record struct AobHostOutcome( + AobScanOutcomeKind Kind, + LuaStatus LuaStatus, + int ResultCount, + TargetSelectionFacts TargetBefore, + TargetSelectionFacts TargetAfter) +{ + /// + /// Gets whether both observations are qualified and denote the same process incarnation, the SDK's + /// AobScanTargetContext.IsSameQualifiedIncarnation. + /// + internal bool IsSameQualifiedIncarnation => + TargetBefore.IsQualified && TargetAfter.IsQualified && TargetBefore.Incarnation == TargetAfter.Incarnation; +} diff --git a/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs b/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs deleted file mode 100644 index ef36dd5..0000000 --- a/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CheatEngine.Client.Core.Domains; - -/// Classifies the SDK AOB result-list boundary without exposing its ownership handle. -internal enum AobScanHostStatus -{ - Success, - Rejected, - InvalidResult -} diff --git a/libs/CheatEngine.Client.Core/Domains/AobScanMapping.cs b/libs/CheatEngine.Client.Core/Domains/AobScanMapping.cs new file mode 100644 index 0000000..acec10e --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/AobScanMapping.cs @@ -0,0 +1,490 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Maps the outcomes of CheatEngine.SDK 2.0.0 AobScanner.TryScanOutcome (with its target context) and +/// AobScanner.TryScanWithinBounds to the Client vocabulary, value by value (audit F06, F07, CRIT-03). +/// +/// +/// Global route, outcomes that hand out no usable result list: +/// +/// +/// SDK outcome +/// Client failure kind and host effect +/// +/// +/// NoResult +/// +/// IndeterminateHostResult, Completed, with : on Cheat Engine +/// 7.7 zero matches and host failures both return nil, so this is never NotFound. +/// +/// +/// +/// GlobalUnavailable +/// CapabilityUnavailable, NotStarted: AOBScan was not called. +/// +/// ProtectedLuaFailureLuaError, Unknown, naming the Lua status +/// InvalidResultInvalidHostResult, Completed +/// +/// ResultListCountUnavailable +/// InvalidHostResult, Completed: CheatEngine.SDK released the list itself. +/// +/// +/// Matches or NoMatches without a list +/// InvalidHostResult, Completed: a broken SDK contract, never a success. +/// +/// +/// Unknown or undefined +/// IndeterminateHostResult, Unknown +/// +/// +/// +/// Matches and NoMatches with a list are copied; NoMatches is the factual empty success. +/// Before anything is copied, and for NoResult, the target context decides whether the answer can be +/// attributed to one target (): a target that changed during the scan is +/// , and a target whose identity was lost or gained during the +/// scan is , both with +/// ; the returned addresses are discarded. Messages name the +/// category only, never an address, a process or a pattern. The mapping-totality tests fail when the consumed SDK +/// adds a value. +/// +/// Bounded route (): +/// +/// +/// SDK outcome +/// Client result +/// +/// MatchesThe in-bounds addresses are published. +/// +/// NoMatches +/// +/// The factual empty success when Cheat Engine's error text was read; otherwise +/// IndeterminateHostResult, Completed: a host error cannot be excluded. +/// +/// +/// +/// HostReportedError +/// OperationRejected, Completed, carrying the SDK's bounded, unparsed text. +/// +/// InvalidBoundsOperationRejected, NotStarted +/// +/// SessionCreationFailed, TargetIdentityUnavailable +/// +/// Fall back to the global route with managed post-filters; a creation whose rollback Cheat Engine did not +/// confirm, or whose creation status this Client does not recognize, is IndeterminateHostResult, +/// CleanupUnconfirmed instead (), as a value-scan session +/// creation is. +/// +/// +/// TargetChangedTargetChanged +/// RuntimeInvalidatedRuntimeChanged +/// ScanFailedLuaError, naming the Lua status +/// InvalidResultInvalidHostResult +/// +/// Cancelled +/// +/// Cancelled: NotStarted before the scan completed (no host scan time), Completed +/// after it. +/// +/// +/// +/// WaitTimedOut, Unknown or undefined +/// IndeterminateHostResult, Unknown: this Client never sets a call deadline. +/// +/// +/// +/// The host effect of TargetChanged, RuntimeInvalidated, ScanFailed and +/// InvalidResult is Completed when the scan had completed (the SDK reported a host scan time) and +/// Unknown otherwise. The session release is checked separately +/// (): an unconfirmed release turns any of these results into +/// CleanupUnconfirmed. +/// +/// +internal static class AobScanMapping +{ + /// The exact message of the global route's NoResult outcome. + internal const string NoResultMessage = + "CE AOBScan returned nil: on CE 7.7 zero matches and host failures share this shape"; + + /// The message of a result list the scanner cannot use. + internal const string InvalidListMessage = "Cheat Engine returned an invalid AOB result list."; + + /// Returns the failure of a global outcome that hands out no usable result list. + /// The public Client operation name. + /// The copied SDK outcome. + /// The classified failure; never a success. + internal static CheatEngineFailure ToFailure(string operation, AobHostOutcome host) + { + return host.Kind switch + { + AobScanOutcomeKind.NoResult => new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, + operation, NoResultMessage, null, CheatEngineHostEffect.Completed), + AobScanOutcomeKind.GlobalUnavailable => new CheatEngineFailure( + CheatEngineFailureKind.CapabilityUnavailable, operation, + "Cheat Engine's AOBScan global is absent or not callable.", null, CheatEngineHostEffect.NotStarted), + AobScanOutcomeKind.ProtectedLuaFailure => new CheatEngineFailure(CheatEngineFailureKind.LuaError, + operation, $"The protected AOBScan call failed with Lua status {host.LuaStatus}.", null, + CheatEngineHostEffect.Unknown), + AobScanOutcomeKind.InvalidResult => new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, + operation, "Cheat Engine's AOBScan returned a value that is not a result list.", null, + CheatEngineHostEffect.Completed), + AobScanOutcomeKind.ResultListCountUnavailable => new CheatEngineFailure( + CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine returned an AOB result list whose count could not be read; CheatEngine.SDK released it.", + null, CheatEngineHostEffect.Completed), + AobScanOutcomeKind.Matches or AobScanOutcomeKind.NoMatches => new CheatEngineFailure( + CheatEngineFailureKind.InvalidHostResult, operation, InvalidListMessage, null, + CheatEngineHostEffect.Completed), + AobScanOutcomeKind.Unknown => Indeterminate(operation), + _ => Indeterminate(operation) + }; + } + + /// Decides whether the answer of one global scan can be attributed to one target. + /// The selection observed immediately before the call. + /// The selection observed immediately after the call returned. + /// + /// when both observations are qualified and denote the same incarnation; + /// when they denote different incarnations or different process + /// identifiers; when only one of them is qualified, or when + /// neither is and they differ otherwise; when neither is qualified and + /// both report the same selection (a remote, file-as-process or unobservable target that did not visibly change). + /// + internal static AobTargetVerdict JudgeTarget(TargetSelectionFacts before, TargetSelectionFacts after) + { + if (before.IsQualified && after.IsQualified) + { + return before.Incarnation == after.Incarnation ? AobTargetVerdict.Verified : AobTargetVerdict.Changed; + } + + if (before.SelectedProcessId is { } first && after.SelectedProcessId is { } second && first != second) + { + return AobTargetVerdict.Changed; + } + + return before.IsQualified || after.IsQualified || before != after + ? AobTargetVerdict.IdentityUnavailable + : AobTargetVerdict.Unverified; + } + + /// Returns the failure that discards the answer of a scan whose target cannot be attributed. + /// The public Client operation name. + /// The target verdict of the scan. + /// The failure when the answer is discarded. + /// when the verdict discards the answer. + internal static bool TryGetTargetFailure(string operation, AobTargetVerdict verdict, out CheatEngineFailure failure) + { + switch (verdict) + { + case AobTargetVerdict.Changed: + failure = new CheatEngineFailure(CheatEngineFailureKind.TargetChanged, operation, + "Cheat Engine's selected target changed during the AOB scan; its answer was discarded.", null, + CheatEngineHostEffect.Completed); + return true; + case AobTargetVerdict.IdentityUnavailable: + failure = new CheatEngineFailure(CheatEngineFailureKind.TargetIdentityUnavailable, operation, + "The selected target could not be identified the same way before and after the AOB scan; its " + + "answer was discarded.", null, CheatEngineHostEffect.Completed); + return true; + default: + failure = default; + return false; + } + } + + /// Decides what the scanner does with a bounded result, before its session release is checked. + /// The public Client operation name. + /// The copied SDK result. + /// + /// The failure for , and the reason of a + /// (reported only when the fallback cannot run); default otherwise. + /// + /// Whether to publish the addresses, fall back to the global route, or fail. + internal static AobBoundedDisposition ClassifyBounded(string operation, in AobBoundedHostResult result, + out CheatEngineFailure failure) + { + failure = default; + switch (result.Kind) + { + case AobBoundedScanOutcomeKind.Matches: + return AobBoundedDisposition.Publish; + case AobBoundedScanOutcomeKind.NoMatches when !result.IsHostErrorTextUnreadable: + return AobBoundedDisposition.Publish; + case AobBoundedScanOutcomeKind.NoMatches: + failure = new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "The bounded AOB scan found no in-bounds match, but Cheat Engine's error text could not be read, " + + "so a host error cannot be excluded.", null, CheatEngineHostEffect.Completed); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.HostReportedError: + string suffix = result.IsHostErrorTextTruncated ? " (truncated)" : string.Empty; + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + $"Cheat Engine reported an error for the bounded AOB scan: {result.HostErrorText}{suffix}", null, + CheatEngineHostEffect.Completed); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.InvalidBounds: + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + "CheatEngine.SDK refused the bounded AOB scan before any Cheat Engine call: its bounds are empty.", + null, CheatEngineHostEffect.NotStarted); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.SessionCreationFailed: + return ClassifyCreationFailure(operation, result.CreationStatus, out failure); + case AobBoundedScanOutcomeKind.TargetIdentityUnavailable: + failure = new CheatEngineFailure(CheatEngineFailureKind.TargetIdentityUnavailable, operation, + "The selected target could not be qualified during the bounded AOB scan; nothing was published.", + null, ScanEffect(result)); + return AobBoundedDisposition.FallBack; + case AobBoundedScanOutcomeKind.TargetChanged: + failure = new CheatEngineFailure(CheatEngineFailureKind.TargetChanged, operation, + "Cheat Engine's selected target changed during the bounded AOB scan; nothing was published.", null, + ScanEffect(result)); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.RuntimeInvalidated: + failure = new CheatEngineFailure(CheatEngineFailureKind.RuntimeChanged, operation, + "The Lua runtime changed during the bounded AOB scan; nothing was published.", null, + ScanEffect(result)); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.ScanFailed: + failure = new CheatEngineFailure(CheatEngineFailureKind.LuaError, operation, + $"A protected call of the bounded AOB scan failed with Lua status {result.LuaStatus}.", null, + ScanEffect(result)); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.InvalidResult: + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine returned a malformed wait result, count or row address for the bounded AOB scan.", + null, ScanEffect(result)); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.Cancelled: + failure = result.HostScanElapsed > TimeSpan.Zero + ? CancellationMapping.AfterNativeCall(operation, + "The bounded AOB scan was cancelled after Cheat Engine completed it; nothing was published.") + : CancellationMapping.BeforeNativeCall(operation, + "The bounded AOB scan was cancelled before Cheat Engine started it."); + return AobBoundedDisposition.Fail; + case AobBoundedScanOutcomeKind.WaitTimedOut: + failure = new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "The bounded AOB scan reported a call deadline, which this Client never sets.", null, + CheatEngineHostEffect.Unknown); + return AobBoundedDisposition.Fail; + default: + failure = new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "CheatEngine.SDK reported a bounded AOB outcome this Client version does not recognize.", null, + CheatEngineHostEffect.Unknown); + return AobBoundedDisposition.Fail; + } + } + + /// Decides what a bounded scan whose session could not be created means, by creation status. + /// The public Client operation name. + /// The SDK's session creation status. + /// The failure, or the reason of the fallback (reported only when it cannot run). + /// + /// for every status after which CheatEngine.SDK holds no MemScan + /// object (an absent or failing factory, an absent, invalid or aliased result, an unqualified target); + /// otherwise. + /// + /// + /// RollbackUnconfirmed is with + /// : a MemScan object Cheat Engine did not destroy is never + /// hidden behind a second scan. Unknown, Success (a contradiction with a failed creation) and a value + /// this Client version does not recognize fail closed the same way, because nothing proves that no object remains. + /// The value-scan sessions classify the same statuses the same way (ValueScanMapping): an unconfirmed host + /// rollback is an indeterminate host result, never a Client state + /// (). + /// + internal static AobBoundedDisposition ClassifyCreationFailure(string operation, MemoryScanCreationStatus status, + out CheatEngineFailure failure) + { + switch (status) + { + case MemoryScanCreationStatus.GlobalUnavailable: + case MemoryScanCreationStatus.LuaFailure: + case MemoryScanCreationStatus.NoScannerResult: + case MemoryScanCreationStatus.InvalidScannerResult: + case MemoryScanCreationStatus.NoFoundListResult: + case MemoryScanCreationStatus.InvalidFoundListResult: + case MemoryScanCreationStatus.AliasedFoundList: + case MemoryScanCreationStatus.TargetIdentityUnavailable: + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, + $"The bounded AOB scan session could not be created ({status}).", null, + CheatEngineHostEffect.NotStarted); + return AobBoundedDisposition.FallBack; + case MemoryScanCreationStatus.RollbackUnconfirmed: + failure = new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "The bounded AOB scan session could not be created, and Cheat Engine did not confirm its rollback.", + null, CheatEngineHostEffect.CleanupUnconfirmed); + return AobBoundedDisposition.Fail; + default: + failure = new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "The bounded AOB scan session could not be created, and CheatEngine.SDK reported a creation status " + + "this Client version does not recognize, so no MemScan object is known to have been removed.", null, + CheatEngineHostEffect.CleanupUnconfirmed); + return AobBoundedDisposition.Fail; + } + } + + /// Translates the Client-owned protection filter and alignment rule into CheatEngine.SDK's scan options. + /// + /// The protection filter; an all-unspecified filter is the empty protection text, which CheatEngine.SDK documents as + /// Cheat Engine's "find everything" value. + /// + /// The alignment rule. + /// + /// The SDK options: the protection text in Cheat Engine's order (X, C, W; + required, + /// - excluded, * either), and the fast-scan method with its decimal divisor or upper-case digits. + /// + /// + /// + /// The protection text is always explicit, so both routes send Cheat Engine the same argument: the bounded route + /// turns an omitted text into the empty string itself, and the global AOBScan receives the empty string + /// instead of an omitted or nil argument, whose meaning the SDK does not document. + /// + /// + /// The public values validate themselves when they are created; + /// throws for an undefined value before dispatch, so this + /// translation never sees one. + /// + /// + internal static AobScanOptions ToSdkOptions(ScanProtectionFilter protection, ScanAlignment alignment) + { + // The AOB options omit the alignment parameter without alignment (ScanOptionTranslation). + (FastScanMethod method, string? parameter) = ScanOptionTranslation.ToFastScan(alignment, null); + return new AobScanOptions(ScanOptionTranslation.ToProtectionText(protection), method, parameter); + } + + /// Returns the public host outcome of a global scan. + /// The SDK outcome. + /// The same category; for an undefined value. + internal static PatternScanHostOutcomeKind ToHostOutcome(AobScanOutcomeKind kind) + { + return kind switch + { + AobScanOutcomeKind.Unknown => PatternScanHostOutcomeKind.Unknown, + AobScanOutcomeKind.Matches => PatternScanHostOutcomeKind.Matches, + AobScanOutcomeKind.NoMatches => PatternScanHostOutcomeKind.NoMatches, + AobScanOutcomeKind.GlobalUnavailable => PatternScanHostOutcomeKind.GlobalUnavailable, + AobScanOutcomeKind.ProtectedLuaFailure => PatternScanHostOutcomeKind.ProtectedLuaFailure, + AobScanOutcomeKind.NoResult => PatternScanHostOutcomeKind.NoResult, + AobScanOutcomeKind.InvalidResult => PatternScanHostOutcomeKind.InvalidResult, + AobScanOutcomeKind.ResultListCountUnavailable => PatternScanHostOutcomeKind.ResultListCountUnavailable, + _ => PatternScanHostOutcomeKind.Unknown + }; + } + + /// Returns the public host outcome of a bounded scan. + /// The SDK outcome. + /// + /// The same category, in the Client's words: ScanFailed is + /// , which the global route also reports, and + /// RuntimeInvalidated is . + /// InvalidBounds (refused before any Cheat Engine call), SessionCreationFailed (no scan ran; + /// the request falls back) and WaitTimedOut (a deadline this Client never sets) have no host outcome + /// and are , like an undefined value. + /// + internal static PatternScanHostOutcomeKind ToHostOutcome(AobBoundedScanOutcomeKind kind) + { + return kind switch + { + AobBoundedScanOutcomeKind.Unknown => PatternScanHostOutcomeKind.Unknown, + AobBoundedScanOutcomeKind.Matches => PatternScanHostOutcomeKind.Matches, + AobBoundedScanOutcomeKind.NoMatches => PatternScanHostOutcomeKind.NoMatches, + AobBoundedScanOutcomeKind.InvalidBounds => PatternScanHostOutcomeKind.Unknown, + AobBoundedScanOutcomeKind.SessionCreationFailed => PatternScanHostOutcomeKind.Unknown, + AobBoundedScanOutcomeKind.ScanFailed => PatternScanHostOutcomeKind.ProtectedLuaFailure, + AobBoundedScanOutcomeKind.WaitTimedOut => PatternScanHostOutcomeKind.Unknown, + AobBoundedScanOutcomeKind.HostReportedError => PatternScanHostOutcomeKind.HostReportedError, + AobBoundedScanOutcomeKind.InvalidResult => PatternScanHostOutcomeKind.InvalidResult, + AobBoundedScanOutcomeKind.TargetChanged => PatternScanHostOutcomeKind.TargetChanged, + AobBoundedScanOutcomeKind.TargetIdentityUnavailable => PatternScanHostOutcomeKind.TargetIdentityUnavailable, + AobBoundedScanOutcomeKind.RuntimeInvalidated => PatternScanHostOutcomeKind.RuntimeChanged, + AobBoundedScanOutcomeKind.Cancelled => PatternScanHostOutcomeKind.Cancelled, + _ => PatternScanHostOutcomeKind.Unknown + }; + } + + /// Gets whether the SDK read the host result count of a bounded scan, so its metrics are meaningful. + /// + /// A completed copy always read the count. CheatEngine.SDK also reads it before any row, and keeps its accounting + /// on a failure, so a row that could not be read or a cancellation observed between rows still carries it: a + /// non-zero count or a read row proves that the count was read. + /// + internal static bool HasReadCount(in AobBoundedHostResult result) + { + return result.Kind is AobBoundedScanOutcomeKind.Matches or AobBoundedScanOutcomeKind.NoMatches + or AobBoundedScanOutcomeKind.HostReportedError || result.RowsRead > 0 || result.HostResultCount > 0; + } + + /// Checks the one child-before-parent release of a bounded scan's MemScan session. + /// The copied SDK result. + /// The combined release kind (). + /// + /// when no session was created, or when both owners report Released and a scan that + /// may still have been running needed no stop or had its stop confirmed. + /// + internal static bool IsSessionReleaseConfirmed(in AobBoundedHostResult result, out LeaseReleaseKind released) + { + if (result.CreationStatus != MemoryScanCreationStatus.Success || + result.Kind == AobBoundedScanOutcomeKind.SessionCreationFailed) + { + // No session was published, so there is nothing to release; an unconfirmed rollback is its own failure. + released = LeaseReleaseKind.Released; + return true; + } + + released = SdkReleaseOutcomes.Worst(SdkReleaseOutcomes.FromTarget(result.FoundListRelease), + SdkReleaseOutcomes.FromTarget(result.MemScanRelease)).Kind; + if (released == LeaseReleaseKind.Released && !ScanTermination.IsStopConfirmed(result.ReleaseTermination)) + { + released = LeaseReleaseKind.CleanupUnconfirmed; + } + + return released == LeaseReleaseKind.Released; + } + + /// The effect of a failure the SDK observed during a bounded scan: completed only after the scan completed. + private static CheatEngineHostEffect ScanEffect(in AobBoundedHostResult result) + { + return result.HostScanElapsed > TimeSpan.Zero ? CheatEngineHostEffect.Completed : CheatEngineHostEffect.Unknown; + } + + private static CheatEngineFailure Indeterminate(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "CheatEngine.SDK reported an AOB outcome this Client version does not recognize.", null, + CheatEngineHostEffect.Unknown); + } +} + +/// What the scanner does with the result of a bounded scan. +internal enum AobBoundedDisposition +{ + /// The scan failed; report its failure. + Fail = 0, + + /// The scan succeeded; publish its in-bounds addresses. + Publish = 1, + + /// The bounded route could not run on this target; run the global route with managed post-filters. + FallBack = 2 +} + +/// Whether the answer of one scan can be attributed to the target the caller expected. +internal enum AobTargetVerdict +{ + /// No target could be qualified, but the selection did not visibly change: the answer is kept, unverified. + Unverified = 0, + + /// One qualified incarnation before and after the call: the answer is kept and verified. + Verified = 1, + + /// The selection changed during the call: the answer is discarded. + Changed = 2, + + /// The identity was lost, gained or otherwise differed during the call: the answer is discarded. + IdentityUnavailable = 3 +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/AssemblyClient.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/AssemblyClient.cs new file mode 100644 index 0000000..44c92f5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/AssemblyClient.cs @@ -0,0 +1,372 @@ +using System.Collections.Immutable; +using System.Runtime.InteropServices; + +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// The instruction client over CheatEngine.SDK's instruction assembler, disassembler and navigator. +/// +/// +/// Every call follows the Client order: request validation, activation admission, cancellation observed before +/// dispatch, then one dispatched callback on Cheat Engine's main thread. Inside it the port asks CheatEngine.SDK +/// for one Lua admission, the instruction profile is observed once, the address is checked against the profile's +/// width, and the operation runs with that same profile. A cancellation is never observed after the dispatch: +/// no instruction operation changes the target. +/// +/// +/// assembles into a buffer of bytes, bounded by +/// . When Cheat Engine's result is longer, it retries once +/// with the exact length CheatEngine.SDK reported, if that length is within the bound. An empty result is an +/// invalid host result: one instruction is never zero bytes long. +/// +/// +/// gets the instruction length, reads that many bytes from target memory, then +/// disassembles: the byte read sits between two of CheatEngine.SDK's selected-process checks, and the bytes are +/// never parsed from the disassembler's byte column. Statuses are mapped by , +/// memory failures by , and SDK faults are translated by +/// . A step that follows an earlier instruction call (the retry, the byte read, the +/// disassembly) never reports . +/// +/// +internal sealed class AssemblyClient : IAssemblyClient +{ + /// The operation name of an assembly. + internal const string AssembleOperation = "Assembly.Assemble"; + + /// The operation name of a disassembly. + internal const string DisassembleOperation = "Assembly.Disassemble"; + + /// The operation name of an instruction-length query. + internal const string GetInstructionLengthOperation = "Assembly.GetInstructionLength"; + + /// The operation name of a previous-instruction query. + internal const string GetPreviousInstructionAddressOperation = "Assembly.GetPreviousInstructionAddress"; + + /// The size of the first assembly buffer: the longest x86 instruction takes 15 bytes. + internal const int InitialAssemblyCapacity = 16; + + private readonly ICheatEngineDispatcher _dispatcher; + private readonly CoreLifetime _lifetime; + private readonly MemoryResourceLimits _limits; + private readonly IInstructionPort _port; + + internal AssemblyClient(SdkMainThreadDispatcher dispatcher, CoreLifetime lifetime, MemoryResourceLimits limits) + : this(dispatcher, lifetime, limits, SdkInstructionPort.Instance) + { + } + + /// Creates the client over an explicit port; tests supply a fake port and dispatcher. + internal AssemblyClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime, MemoryResourceLimits limits, + IInstructionPort port) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + _limits = MemoryResourceLimitsCopy.CreateValidated(limits); + _port = port ?? throw new ArgumentNullException(nameof(port)); + } + + public bool TryAssemble(AssemblyInstructionRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + string instruction = request.Instruction ?? + throw new ArgumentException("The default instruction request has no instruction.", + nameof(request)); + AssemblePreference preference = ToSdk(request.Preference); + return TryRun(AssembleOperation, request.Address, + profile => AssembleOnMainThread(profile, instruction, request.Address, preference, request.SkipRangeCheck), + out bytes, out failure, cancellationToken); + } + + public ImmutableArray Assemble(AssemblyInstructionRequest request, + CancellationToken cancellationToken = default) + { + if (TryAssemble(request, out ImmutableArray bytes, out CheatEngineFailure failure, cancellationToken)) + { + return bytes; + } + + failure.Throw(cancellationToken); + return default; + } + + public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryRun(DisassembleOperation, address, profile => DisassembleOnMainThread(profile, address), + out instruction, out failure, cancellationToken); + } + + public AssemblyInstructionSnapshot Disassemble(Address address, CancellationToken cancellationToken = default) + { + if (TryDisassemble(address, out AssemblyInstructionSnapshot instruction, out CheatEngineFailure failure, + cancellationToken)) + { + return instruction; + } + + failure.Throw(cancellationToken); + return default; + } + + public bool TryGetInstructionLength(Address address, out int length, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryRun(GetInstructionLengthOperation, address, profile => GetLengthOnMainThread(profile, address), + out length, out failure, cancellationToken); + } + + public int GetInstructionLength(Address address, CancellationToken cancellationToken = default) + { + if (TryGetInstructionLength(address, out int length, out CheatEngineFailure failure, cancellationToken)) + { + return length; + } + + failure.Throw(cancellationToken); + return default; + } + + public bool TryGetPreviousInstructionAddress(Address address, out Address previousAddress, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryRun(GetPreviousInstructionAddressOperation, address, + profile => GetPreviousOnMainThread(profile, address), out previousAddress, out failure, + cancellationToken); + } + + public Address GetPreviousInstructionAddress(Address address, CancellationToken cancellationToken = default) + { + if (TryGetPreviousInstructionAddress(address, out Address previous, out CheatEngineFailure failure, + cancellationToken)) + { + return previous; + } + + failure.Throw(cancellationToken); + return default; + } + + /// Maps the Client encoding preference to CheatEngine.SDK's, value by value. + private static AssemblePreference ToSdk(InstructionEncodingPreference preference) + { + return preference switch + { + InstructionEncodingPreference.None => AssemblePreference.None, + InstructionEncodingPreference.Short => AssemblePreference.Short, + InstructionEncodingPreference.Long => AssemblePreference.Long, + InstructionEncodingPreference.Far => AssemblePreference.Far, + _ => throw new ArgumentOutOfRangeException(nameof(preference), preference, + "The encoding preference must be None, Short, Long or Far.") + }; + } + + private static Attempt Fail(CheatEngineFailure failure) + { + return new Attempt(false, default!, failure); + } + + private static Attempt Succeed(T value) + { + return new Attempt(true, value, default); + } + + /// + /// Admits the activation, observes cancellation, then runs one dispatched callback: Lua admission, one profile + /// observation, the address check and . + /// + private bool TryRun(string operation, Address address, Func> work, + out T result, out CheatEngineFailure failure, CancellationToken cancellationToken) + { + result = default!; + _lifetime.ThrowIfInactive(operation); + if (cancellationToken.IsCancellationRequested) + { + failure = CancellationMapping.BeforeNativeCall(operation); + return false; + } + + Attempt attempt = default; + if (!SdkBoundary.TryInvoke(_dispatcher, operation, + () => attempt = RunOnMainThread(operation, address, work), CheatEngineHostEffect.Unknown, _lifetime, + out failure, cancellationToken)) + { + return false; + } + + if (!attempt.Succeeded) + { + failure = attempt.Failure; + return false; + } + + result = attempt.Value; + failure = default; + return true; + } + + private Attempt RunOnMainThread(string operation, Address address, + Func> work) + { + Attempt attempt = default; + if (!_port.TryRunAdmitted(operation, () => + { + InstructionOperationStatus status = _port.ObserveProfile(out InstructionProfileObservation profile); + if (InstructionMapping.ToFailure(operation, status, InstructionCallPhase.ProfileObservation) is { } refused) + { + attempt = Fail(refused); + } + else + { + attempt = profile.Accepts(address) + ? work(profile) + : Fail(InstructionMapping.AddressOutsideProfile(operation)); + } + }, out CheatEngineFailure admissionFailure)) + { + return Fail(admissionFailure); + } + + return attempt; + } + + private Attempt> AssembleOnMainThread(InstructionProfileObservation profile, + string instruction, Address address, AssemblePreference preference, bool skipRangeCheck) + { + int limit = _limits.MaximumReadBytes; + byte[] buffer = new byte[Math.Min(InitialAssemblyCapacity, limit)]; + InstructionCallPhase phase = InstructionCallPhase.Operation; + InstructionOperationStatus status = _port.Assemble(profile, instruction, address, preference, skipRangeCheck, + buffer, out int written, out int requiredLength); + if (status == InstructionOperationStatus.DestinationTooSmall && requiredLength > buffer.Length) + { + if (requiredLength > limit) + { + return Fail>( + InstructionMapping.ResultExceedsLimit(AssembleOperation, requiredLength, limit)); + } + + // The one retry, with the exact length CheatEngine.SDK reported and the same profile. + buffer = new byte[requiredLength]; + phase = InstructionCallPhase.AfterEarlierCall; + status = _port.Assemble(profile, instruction, address, preference, skipRangeCheck, buffer, out written, + out _); + } + + if (InstructionMapping.ToFailure(AssembleOperation, status, phase) is { } failure) + { + return Fail>(failure); + } + + if (written == 0) + { + // CheatEngine.SDK reports an empty byte table as a success; one instruction is never zero bytes long. + return Fail>(InstructionMapping.InvalidResult(AssembleOperation, + "CheatEngine.SDK reported an empty assembled instruction; nothing was copied.")); + } + + if (written < 0 || written > buffer.Length) + { + return Fail>(InstructionMapping.InvalidResult(AssembleOperation, + "CheatEngine.SDK reported an assembled length outside the Client buffer; nothing was copied.")); + } + + return Succeed(written == buffer.Length + ? ImmutableCollectionsMarshal.AsImmutableArray(buffer) + : ImmutableArray.Create(buffer, 0, written)); + } + + private Attempt DisassembleOnMainThread(InstructionProfileObservation profile, + Address address) + { + InstructionOperationStatus status = _port.GetLength(profile, address, out int length); + if (InstructionMapping.ToFailure(DisassembleOperation, status, InstructionCallPhase.Operation) is { } failure) + { + return Fail(failure); + } + + if (length <= 0) + { + return Fail(InstructionMapping.InvalidResult(DisassembleOperation, + "CheatEngine.SDK reported an instruction length that is not positive.")); + } + + if (length > _limits.MaximumReadBytes) + { + return Fail( + InstructionMapping.ResultExceedsLimit(DisassembleOperation, length, _limits.MaximumReadBytes)); + } + + // The byte read and the disassembly follow the length query, which already called Cheat Engine: a step refused + // before its own call is Completed, never NotStarted. + byte[] bytes = new byte[length]; + if (!_port.TryReadBytes(address, bytes, out int written, out MemoryAccessFailure readFailure)) + { + // The snapshot is all or nothing: a confirmed prefix is only named in the message. + int confirmed = written > 0 && written < length ? written : 0; + return Fail(InstructionMapping.AfterEarlierCall( + MemoryAccessFailureMapping.ToByteReadFailure(DisassembleOperation, readFailure, confirmed, length))); + } + + status = _port.Disassemble(profile, address, _limits.MaximumStringBytes, + out InstructionDisassembly disassembly); + CheatEngineFailure? refused = + InstructionMapping.ToFailure(DisassembleOperation, status, InstructionCallPhase.AfterEarlierCall); + if (refused is { } disassemblyFailure) + { + return Fail(disassemblyFailure); + } + + if (disassembly.AddressText is null || string.IsNullOrWhiteSpace(disassembly.Opcode) || + disassembly.Extra is null) + { + return Fail(InstructionMapping.InvalidResult(DisassembleOperation, + "Cheat Engine's disassembler returned no instruction text.")); + } + + return Succeed(new AssemblyInstructionSnapshot(address, length, disassembly.AddressText, disassembly.Opcode, + disassembly.Extra, bytes)); + } + + private Attempt GetLengthOnMainThread(InstructionProfileObservation profile, Address address) + { + InstructionOperationStatus status = _port.GetLength(profile, address, out int length); + CheatEngineFailure? failure = + InstructionMapping.ToFailure(GetInstructionLengthOperation, status, InstructionCallPhase.Operation); + if (failure is { } refused) + { + return Fail(refused); + } + + return length > 0 + ? Succeed(length) + : Fail(InstructionMapping.InvalidResult(GetInstructionLengthOperation, + "CheatEngine.SDK reported an instruction length that is not positive.")); + } + + private Attempt
GetPreviousOnMainThread(InstructionProfileObservation profile, Address address) + { + InstructionOperationStatus status = _port.GetPrevious(profile, address, out Address previous); + if (status == InstructionOperationStatus.AddressExceedsProfileWidth || + (status == InstructionOperationStatus.Success && !profile.Accepts(previous))) + { + // The input was accepted before the call, so the width refusal is Cheat Engine's estimate. + return Fail
(InstructionMapping.ReturnedAddressOutsideProfile(GetPreviousInstructionAddressOperation)); + } + + CheatEngineFailure? failure = + InstructionMapping.ToFailure(GetPreviousInstructionAddressOperation, status, InstructionCallPhase.Operation); + return failure is { } refused ? Fail
(refused) : Succeed(previous); + } + + /// The result of one dispatched call, or the failure that replaced it. + private readonly record struct Attempt(bool Succeeded, T Value, CheatEngineFailure Failure); +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerClient.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerClient.cs new file mode 100644 index 0000000..7e3e496 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerClient.cs @@ -0,0 +1,301 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Assembly; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// The opt-in Auto Assembler client over CheatEngine.SDK's AutoAssemblerPatcher. +/// +/// +/// Every call follows the Client order: request validation, activation admission, cancellation observed before +/// dispatch, the EnableAutoAssemblerPatches policy gate (audit Q44: a refusal makes no Cheat Engine call), +/// then one dispatched callback on Cheat Engine's main thread. A cancellation is never observed after the +/// dispatch: an applied patch is always returned as a lease, because a token cannot undo it. +/// +/// +/// Inside the callback the Client refuses an activation that stopped or ended since its admission, before Cheat +/// Engine applies a patch that no lease could own, and asks the port to apply the script. It then registers +/// the lease with the activation and with the target selection of the process incarnation that CheatEngine.SDK +/// bound the patch to (), before the callback returns: a process selected +/// in Cheat Engine's own window since the last observation advances the epoch first, so the next observation +/// never releases a patch whose own process is still selected. When the registration is refused, the patch is +/// released at once through its owner and reports what that release left. +/// Outcomes are mapped by ; SDK faults are translated by +/// . +/// +/// +/// The SDK options are bounded here: host text (rejection detail, warnings, check messages) is copied up to +/// UTF-8 bytes, and the SDK's disable-info snapshot, which the Client does not +/// project, is copied at the SDK minimum. +/// +/// +internal sealed class AutoAssemblerClient : IAutoAssemblerClient +{ + /// The operation name of a syntax check. + internal const string CheckOperation = "AutoAssembler.Check"; + + /// The operation name of an activation. + internal const string ApplyOperation = "AutoAssembler.ApplyPatch"; + + /// The largest number of UTF-8 bytes copied from one Cheat Engine host text. + internal const int HostTextByteLimit = 4096; + + private const string PatchSubject = "applied Auto Assembler patch"; + + private const string PolicyRefusalMessage = + "Auto Assembler patches were not enabled for this activation; call EnableAutoAssemblerPatches() on the " + + "Client builder."; + + private readonly ICheatEngineDispatcher _dispatcher; + private readonly CoreLifetime _lifetime; + private readonly CoreClientPolicy _policy; + private readonly IAutoAssemblerPort _port; + private readonly ITargetSelectionBinder _selection; + + /// Creates the client of an activation over CheatEngine.SDK's patcher. + /// The activation dispatcher. + /// The activation policy, whose opt-in gates every call. + /// The activation lifetime. + /// The owner of the observed target selection, the activation's process client. + internal AutoAssemblerClient(SdkMainThreadDispatcher dispatcher, CoreClientPolicy policy, CoreLifetime lifetime, + ITargetSelectionBinder selection) + : this(dispatcher, policy, lifetime, selection, SdkAutoAssemblerPort.Instance) + { + } + + /// Creates the client over an explicit port; tests supply a fake port and dispatcher. + internal AutoAssemblerClient(ICheatEngineDispatcher dispatcher, CoreClientPolicy policy, CoreLifetime lifetime, + ITargetSelectionBinder selection, IAutoAssemblerPort port) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _port = port ?? throw new ArgumentNullException(nameof(port)); + } + + /// Gets the bounded SDK options every call uses. + internal static AutoAssemblerOptions Options + { + get; + } = new() + { + CaptureHostText = true, + MaxHostTextBytes = HostTextByteLimit, + MaxDisableInfoEntries = AutoAssemblerOptions.MinDisableInfoEntries, + MaxDisableInfoNameBytes = AutoAssemblerOptions.MinDisableInfoNameBytes + }; + + public bool TryCheck(AutoAssemblerScript script, out AutoAssemblerCheckResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + string source = RequireSource(script); + result = default; + if (!TryAdmit(CheckOperation, out failure, cancellationToken)) + { + return false; + } + + AutoAssemblerCheckFacts facts = default; + CheatEngineFailure admissionFailure = default; + bool admitted = false; + if (!SdkBoundary.TryInvoke(_dispatcher, CheckOperation, + () => admitted = _port.TryCheck(CheckOperation, source, Options, out facts, out admissionFailure), + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) + { + return false; + } + + if (!admitted) + { + failure = admissionFailure; + return false; + } + + return AutoAssemblerMapping.TryMapCheck(CheckOperation, facts, out result, out failure); + } + + public AutoAssemblerCheckResult Check(AutoAssemblerScript script, CancellationToken cancellationToken = default) + { + if (TryCheck(script, out AutoAssemblerCheckResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(cancellationToken); + return default; + } + + public bool TryApplyPatch(AutoAssemblerScript script, [NotNullWhen(true)] out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + string source = RequireSource(script); + string? name = script.Name; + lease = null; + if (!TryAdmit(ApplyOperation, out failure, cancellationToken)) + { + return false; + } + + ApplyAttempt attempt = default; + if (!SdkBoundary.TryInvoke(_dispatcher, ApplyOperation, () => attempt = ApplyOnMainThread(source, name), + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) + { + return false; + } + + _selection.ReportBinding(attempt.Binding, ApplyOperation); + if (attempt.Lease is not { } published) + { + failure = attempt.Failure; + return false; + } + + if (published.AppliedAfterTargetChange) + { + // After the dispatched work returned, never inside it; the operation and the epoch only. + _lifetime.Diagnostics.AutoAssemblerPatchAppliedAfterTargetChange(ApplyOperation, published.SelectionEpoch); + } + + lease = published; + failure = default; + return true; + } + + public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, + CancellationToken cancellationToken = default) + { + if (TryApplyPatch(script, out IAutoAssemblerPatchLease? lease, out CheatEngineFailure failure, + cancellationToken)) + { + return lease; + } + + failure.Throw(cancellationToken); + throw new InvalidOperationException("Unreachable failure flow."); + } + + private static string RequireSource(AutoAssemblerScript script) + { + return script.Source ?? throw new ArgumentException("The default Auto Assembler script has no source.", + nameof(script)); + } + + /// Admits the activation, observes cancellation, then applies the policy gate; nothing is dispatched. + private bool TryAdmit(string operation, out CheatEngineFailure failure, CancellationToken cancellationToken) + { + _lifetime.ThrowIfInactive(operation); + if (cancellationToken.IsCancellationRequested) + { + failure = CancellationMapping.BeforeNativeCall(operation); + return false; + } + + if (!_policy.EnableAutoAssemblerPatches) + { + _lifetime.Diagnostics.CapabilityRefused(ClientCapabilityId.AutoAssemblerPatches.Value, operation, + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, + PolicyRefusalMessage, null, CheatEngineHostEffect.NotStarted); + return false; + } + + failure = default; + return true; + } + + /// Applies the script and publishes its lease, on Cheat Engine's main thread. + private ApplyAttempt ApplyOnMainThread(string source, string? name) + { + // No lease can be registered once the activation stops or ends: refuse before Cheat Engine applies a patch that + // no lease could own. + _lifetime.ThrowIfInactive(ApplyOperation); + AutoAssemblerApplyFacts facts; + IAutoAssemblerPatchOwner? patch; + try + { + if (!_port.TryApply(ApplyOperation, source, Options, out facts, out patch, + out CheatEngineFailure admissionFailure)) + { + return new ApplyAttempt(null, admissionFailure); + } + } + catch (OwnershipHandoffException handoff) + { + // Cheat Engine applied the script, but the port could not publish its owner and the one disable was not + // confirmed: the publication fault keeps its classification, like the AOB route, with CleanupUnconfirmed. + return new ApplyAttempt(null, + OwnershipHandoff.ToFailure(ApplyOperation, handoff, PatchSubject, _lifetime)); + } + + if (AutoAssemblerMapping.ToApplyFailure(ApplyOperation, facts) is { } failure) + { + // The SDK publishes an owner only for an applied script; an owner next to a failure is released at once, + // and a release that is not complete leaves the failure's cleanup unconfirmed, as on every failure path. + if (patch is not null && !AutoAssemblerMapping.ToReleaseOutcome(patch.Release()).IsComplete) + { + failure = CoreFailureFactory.WithHostEffect(failure, CheatEngineHostEffect.CleanupUnconfirmed); + } + + return new ApplyAttempt(null, failure); + } + + if (patch is null) + { + // A success that no owner holds is a result CheatEngine.SDK cannot attribute, as for an allocation or a + // scan session created without its owner. + return new ApplyAttempt(null, new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, + ApplyOperation, + "CheatEngine.SDK reported an applied Auto Assembler script without publishing its owner; the patch may " + + "remain in the target.", null, CheatEngineHostEffect.CleanupUnconfirmed)); + } + + TargetSelectionBinding binding = default; + try + { + // The lease belongs to the selection of the process CheatEngine.SDK bound the patch to, not to the last + // selection the Client observed. + binding = _selection.BindOwner(patch.TargetIncarnation, ApplyOperation); + AutoAssemblerPatchLease created = new(patch, name, binding.SelectionEpoch, + facts.Kind == AutoAssemblerApplyOutcomeKind.AppliedTargetChanged, facts.HostWarnings, + facts.HostWarningsTruncated, _dispatcher, _lifetime.Diagnostics); + created.Register(_lifetime, binding.SelectionEpoch); + return new ApplyAttempt(created, default) + { + Binding = binding + }; + } + catch (Exception registration) + { + // The lease was never published: its owner makes the one disable attempt here, on the main thread. + LeaseReleaseOutcome released = AutoAssemblerMapping.ToReleaseOutcome(patch.Release()); + if (registration is not (CheatEngineClientException or ObjectDisposedException)) + { + throw; + } + + return new ApplyAttempt(null, LeaseRegistration.Refused(_lifetime, ApplyOperation, registration, released, + "the applied Auto Assembler patch")) + { + Binding = binding + }; + } + } + + /// The lease published by one dispatched activation, or the failure that replaced it. + private readonly record struct ApplyAttempt(AutoAssemblerPatchLease? Lease, CheatEngineFailure Failure) + { + /// Gets the selection binding of an applied patch, reported after the callback returned. + internal TargetSelectionBinding Binding + { + get; + init; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerMapping.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerMapping.cs new file mode 100644 index 0000000..78e3bcc --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerMapping.cs @@ -0,0 +1,200 @@ +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// +/// Maps every CheatEngine.SDK 2.0.0 Auto Assembler outcome category to the Client result vocabulary, without reading +/// Cheat Engine's text. +/// +/// +/// Activation (): +/// +/// Applied: success, the patch lease is returned. +/// +/// +/// AppliedTargetChanged: success, the lease reports AppliedAfterTargetChange and the +/// Client logs a warning; the patch stays bound to the target observed before the activation. +/// +/// +/// +/// +/// Rejected: with the SDK's effect, +/// : a rejection does not prove that nothing changed. +/// +/// +/// +/// +/// GlobalUnavailable: , +/// . +/// +/// +/// +/// ProtectedLuaFailure: , unknown effect. +/// +/// +/// InvalidResult: , unknown effect. +/// +/// +/// +/// TargetIdentityUnavailable: , +/// . +/// +/// +/// +/// +/// HandoffFailed: (the category of the SDK's +/// EngineResourceHandoffException) with , +/// whatever the compensation reported: no rollback stronger than Cheat Engine's own disable is promised. +/// +/// +/// +/// +/// Unknown and any value this Client version does not know: +/// with an unknown effect, never a +/// success, like every other SDK outcome the Client does not recognize. +/// +/// +/// +/// +/// Syntax check (): Accepted and Rejected are verdicts +/// (the check succeeded); GlobalUnavailable is +/// with ; ProtectedLuaFailure is +/// , InvalidResult +/// , and Unknown or an unknown value +/// , each with an unknown effect. +/// +/// +/// Release ( of the patch owner): , +/// except NotInvoked, which is terminal for this owner (see ). +/// +/// +/// The host effect of an activation comes from the SDK's own effect state through +/// . The mapping-totality tests fail when the consumed SDK adds a category. +/// +/// +internal static class AutoAssemblerMapping +{ + private const string TruncatedSuffix = " [truncated]"; + + /// Maps an activation outcome; when Cheat Engine applied the script. + /// The public Client operation name. + /// The copied SDK outcome. + /// The failure, or for Applied and AppliedTargetChanged. + internal static CheatEngineFailure? ToApplyFailure(string operation, AutoAssemblerApplyFacts facts) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + CheatEngineHostEffect effect = HostEffectMapping.FromSdk(facts.Effect); + return facts.Kind switch + { + AutoAssemblerApplyOutcomeKind.Applied or AutoAssemblerApplyOutcomeKind.AppliedTargetChanged => null, + AutoAssemblerApplyOutcomeKind.Rejected => new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, + operation, + WithHostText("Cheat Engine rejected the Auto Assembler script; part of its effects may have been applied.", + facts.HostText, facts.HostTextTruncated), + null, effect), + AutoAssemblerApplyOutcomeKind.GlobalUnavailable => new CheatEngineFailure( + CheatEngineFailureKind.CapabilityUnavailable, operation, + "Cheat Engine's autoAssemble function is unavailable; the script was not applied.", null, effect), + AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure => new CheatEngineFailure(CheatEngineFailureKind.LuaError, + operation, + $"The protected Lua call of autoAssemble failed with status '{facts.LuaStatus}'; part of the script may " + + "have been applied.", null, effect), + AutoAssemblerApplyOutcomeKind.InvalidResult => new CheatEngineFailure( + CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine returned an Auto Assembler result outside the documented shapes; the script may have been " + + "applied without disable information.", null, effect), + AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable => new CheatEngineFailure( + CheatEngineFailureKind.TargetIdentityUnavailable, operation, + "The selected target could not be qualified as a process incarnation; the script was not applied.", null, + effect), + AutoAssemblerApplyOutcomeKind.HandoffFailed => new CheatEngineFailure(CheatEngineFailureKind.BindingError, + operation, + "Cheat Engine applied the script, but CheatEngine.SDK could not hand its disable information to an " + + $"owner; its one compensating disable ended as {DescribeCompensation(facts.Compensation)}. The patch may " + + "remain in the target.", null, CheatEngineHostEffect.CleanupUnconfirmed), + _ => new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "CheatEngine.SDK reported no recognized Auto Assembler outcome; the script may have been applied.", null, + CheatEngineHostEffect.Unknown) + }; + } + + /// Maps a syntax-check outcome to a verdict or a failure. + /// The public Client operation name. + /// The copied SDK outcome. + /// The verdict for Accepted and Rejected; otherwise the default value. + /// The failure when the check produced no verdict; otherwise the default value. + /// when Cheat Engine returned a verdict. + internal static bool TryMapCheck(string operation, AutoAssemblerCheckFacts facts, + out AutoAssemblerCheckResult result, out CheatEngineFailure failure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + result = default; + failure = default; + switch (facts.Kind) + { + case AutoAssemblerCheckOutcomeKind.Accepted: + result = new AutoAssemblerCheckResult(true, null, false); + return true; + case AutoAssemblerCheckOutcomeKind.Rejected: + result = new AutoAssemblerCheckResult(false, facts.HostText, + facts.HostText is not null && facts.HostTextTruncated); + return true; + case AutoAssemblerCheckOutcomeKind.GlobalUnavailable: + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, + "Cheat Engine's autoAssembleCheck function is unavailable; the script was not checked.", null, + CheatEngineHostEffect.NotStarted); + return false; + case AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure: + failure = new CheatEngineFailure(CheatEngineFailureKind.LuaError, operation, + $"The protected Lua call of autoAssembleCheck failed with status '{facts.LuaStatus}'.", null, + CheatEngineHostEffect.Unknown); + return false; + case AutoAssemblerCheckOutcomeKind.InvalidResult: + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine returned an Auto Assembler check result outside the documented shape.", null, + CheatEngineHostEffect.Unknown); + return false; + default: + failure = new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, operation, + "CheatEngine.SDK reported no recognized Auto Assembler check outcome.", null, + CheatEngineHostEffect.Unknown); + return false; + } + } + + /// Maps the status of the one release attempt of an Auto Assembler patch owner. + /// The status CheatEngine.SDK's AutoAssemblerPatch.ReleaseWithTargetOutcome reported. + /// The Client outcome; for an unrecognized value. + /// + /// Every status follows except NotInvoked. The shared mapping + /// keeps it retryable (), but CheatEngine.SDK consumes and + /// unroots the patch's disable information on that path as well: the Lua runtime detached, the disable reference + /// is no longer current in the attached Lua state, Lua admission closed, or autoAssemble could not be + /// resolved. No later attempt can run [DISABLE], and the SDK sets its own RequiresManualRecovery. + /// The Client therefore reports with + /// : refused before any Cheat Engine call, never retried, and + /// requiring manual recovery. + /// + internal static LeaseReleaseOutcome ToReleaseOutcome(TargetReleaseStatus status) + { + return status == TargetReleaseStatus.NotInvoked + ? new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted) + : SdkReleaseOutcomes.FromTarget(status); + } + + private static string WithHostText(string message, string? hostText, bool truncated) + { + return hostText is null + ? message + : message + " Cheat Engine reported: " + hostText + (truncated ? TruncatedSuffix : string.Empty); + } + + private static string DescribeCompensation(TargetReleaseStatus? compensation) + { + return compensation is { } status ? status.ToString() : "an unreported status"; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerPatchLease.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerPatchLease.cs new file mode 100644 index 0000000..bec252a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/AutoAssemblerPatchLease.cs @@ -0,0 +1,100 @@ +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// The Client lease of one applied Auto Assembler patch, bound to the target selection it was applied in. +/// +/// +/// The release runs on Cheat Engine's main thread through and calls the SDK +/// owner's ReleaseWithTargetOutcome once: the SDK validates the captured target, then runs +/// [DISABLE] with the disable information Cheat Engine returned. The Client never rebuilds a +/// [DISABLE] section. The status is mapped with . +/// +/// +/// The SDK consumes the disable information on the first attempt that reaches it, whatever the result, so every +/// outcome of that attempt ends the lease: , or a kind that requires +/// manual recovery. An attempt that could not begin the disable (NotInvoked) is +/// , never the retryable +/// , because nothing is left to retry. Only a status this Client +/// version does not recognize stays retryable; a later attempt then makes no Cheat Engine call and reports the +/// recorded status again. +/// +/// +internal sealed class AutoAssemblerPatchLease : HostResourceLease, IAutoAssemblerPatchLease +{ + /// The stable operation name of the release. + internal const string ReleaseOperation = "AutoAssembler.Release"; + + private readonly IAutoAssemblerPatchOwner _patch; + + /// Creates the lease that owns an applied patch; register it with . + /// The sole owner of the patch's disable information. + /// The Client diagnostic name of the script. + /// + /// The target-selection epoch of the process CheatEngine.SDK bound the patch to + /// (). + /// + /// Whether the SDK observed a target change right after the activation. + /// Cheat Engine's bounded compilation warnings. + /// Whether was cut at the bound. + /// The activation dispatcher that runs the release on Cheat Engine's main thread. + /// The activation diagnostics. + internal AutoAssemblerPatchLease(IAutoAssemblerPatchOwner patch, string? name, long selectionEpoch, + bool appliedAfterTargetChange, string? hostWarnings, bool hostWarningsTruncated, + ICheatEngineDispatcher dispatcher, ICoreDiagnostics? diagnostics) + : base(ReleaseOperation, dispatcher, diagnostics) + { + _patch = patch ?? throw new ArgumentNullException(nameof(patch)); + Name = name; + SelectionEpoch = selectionEpoch; + AppliedAfterTargetChange = appliedAfterTargetChange; + HostWarnings = hostWarnings; + HostWarningsTruncated = hostWarnings is not null && hostWarningsTruncated; + } + + public string? Name + { + get; + } + + public long SelectionEpoch + { + get; + } + + public bool CanDisable => !IsReleased && _patch.IsEnabled; + + public bool AppliedAfterTargetChange + { + get; + } + + public string? HostWarnings + { + get; + } + + public bool HostWarningsTruncated + { + get; + } + + /// + /// Gets CheatEngine.SDK's own flag, which only a release attempt sets: the attempt consumed the disable + /// information without a confirmed disable. It adds to the recorded outcome only for a status this Client version + /// does not recognize, whose outcome stays retryable; a replaced Lua state alone clears + /// and leaves the flag unset until a release. + /// + protected override bool OwnerRequiresManualRecovery => _patch.RequiresManualRecovery; + + protected override LeaseReleaseOutcome ReleaseOnMainThread() + { + // One disable attempt per patch: a consumed owner reports the status of the attempt that consumed it. + TargetReleaseStatus status = _patch.IsConsumed ? _patch.LastReleaseStatus : _patch.Release(); + return AutoAssemblerMapping.ToReleaseOutcome(status); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/IAutoAssemblerPort.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/IAutoAssemblerPort.cs new file mode 100644 index 0000000..a2d5f52 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/IAutoAssemblerPort.cs @@ -0,0 +1,119 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// The facts Core copies from one CheatEngine.SDK Auto Assembler activation outcome. +/// The SDK category of the attempt. +/// The SDK effect state, derived by the SDK from only. +/// The protected Lua status of a ProtectedLuaFailure; otherwise Ok. +/// Cheat Engine's bounded rejection detail, when captured. +/// Whether was cut at the bound. +/// Cheat Engine's bounded compilation warnings, when captured. +/// Whether was cut at the bound. +/// The status of the SDK's one compensating disable, for HandoffFailed only. +/// +/// The SDK's disable-info snapshot is deliberately not copied: the Client does not project it (plan A8), and the +/// rooted table the patch owns stays the only disable authority. +/// +internal readonly record struct AutoAssemblerApplyFacts( + AutoAssemblerApplyOutcomeKind Kind, + EngineEffectState Effect, + LuaStatus LuaStatus, + string? HostText, + bool HostTextTruncated, + string? HostWarnings, + bool HostWarningsTruncated, + TargetReleaseStatus? Compensation); + +/// The facts Core copies from one CheatEngine.SDK Auto Assembler syntax check. +/// The SDK category of the check. +/// The protected Lua status of a ProtectedLuaFailure; otherwise Ok. +/// Cheat Engine's bounded error text for a rejected section, when captured. +/// Whether was cut at the bound. +internal readonly record struct AutoAssemblerCheckFacts( + AutoAssemblerCheckOutcomeKind Kind, + LuaStatus LuaStatus, + string? HostText, + bool HostTextTruncated); + +/// The sole owner of one applied patch's disable information, behind the Client lease. +/// +/// The production owner is CheatEngine.SDK's AutoAssemblerPatch. Its first release attempt consumes the +/// disable information before any Lua work, whatever the result; a consumed owner is never released again. +/// +internal interface IAutoAssemblerPatchOwner +{ + /// + /// Gets the process incarnation that CheatEngine.SDK bound the patch to (TargetIncarnation): the target + /// it qualified before the activation, which its release validates again. + /// + public TargetProcessIncarnation TargetIncarnation + { + get; + } + + /// Gets whether the owner still holds disable information that is current in the attached Lua state. + public bool IsEnabled + { + get; + } + + /// Gets whether a release attempt already consumed the disable information. + public bool IsConsumed + { + get; + } + + /// Gets whether the patch may remain after a release attempt that did not confirm the disable. + public bool RequiresManualRecovery + { + get; + } + + /// Gets the status of the release attempt that consumed the owner, or Unspecified before it. + public TargetReleaseStatus LastReleaseStatus + { + get; + } + + /// + /// Makes the one target-validated disable attempt on Cheat Engine's main thread; call it only while + /// is . + /// + /// The factual status of the attempt; never retried. + public TargetReleaseStatus Release(); +} + +/// Internal port for the two Cheat Engine calls of the Auto Assembler domain. +/// +/// Both methods run on Cheat Engine's main thread inside one dispatched callback. Each asks CheatEngine.SDK for Lua +/// admission first and reports a refusal as a classified failure (LuaAdmission) without calling Cheat Engine. +/// Neither passes Cheat Engine's targetself argument, opens or selects a process. +/// +internal interface IAutoAssemblerPort +{ + /// Applies a script once with autoAssemble and publishes the owner of the applied patch. + /// The public Client operation name, for an admission refusal. + /// The complete script. + /// The bounded host-text and snapshot options. + /// The copied outcome when admitted. + /// The owner of the applied patch when the SDK published one; otherwise . + /// The classified admission refusal when not admitted. + /// when the SDK admitted the Lua operation and reported an outcome. + public bool TryApply(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerApplyFacts facts, out IAutoAssemblerPatchOwner? patch, out CheatEngineFailure admissionFailure); + + /// Checks the [ENABLE] section of a script with autoAssembleCheck. + /// The public Client operation name, for an admission refusal. + /// The script to check. + /// The bounded host-text options. + /// The copied outcome when admitted. + /// The classified admission refusal when not admitted. + /// when the SDK admitted the Lua operation and reported an outcome. + public bool TryCheck(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerCheckFacts facts, out CheatEngineFailure admissionFailure); +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/IInstructionPort.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/IInstructionPort.cs new file mode 100644 index 0000000..bbd7183 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/IInstructionPort.cs @@ -0,0 +1,116 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// The instruction profile CheatEngine.SDK observed for one Client call, with the facts Core reads from it. +/// The SDK profile that every instruction call of the same Client call receives. +/// The selected process identifier observed with the profile; not an incarnation. +/// The observed instruction architecture. +/// The observed instruction address width. +/// +/// Only the production port fills : CheatEngine.SDK creates InstructionTargetProfile +/// only through its own observation, so a test port leaves it default and supplies the copied facts. +/// +internal readonly record struct InstructionProfileObservation( + InstructionTargetProfile Sdk, + TargetProcessId Target, + CheatEngineArchitecture Architecture, + PointerSize AddressWidth) +{ + /// Gets whether fits the observed address width. + /// The address to check. + /// + /// only for an address above 4 GiB on a 32-bit profile, the rule CheatEngine.SDK applies + /// before it calls Cheat Engine. + /// + internal bool Accepts(Address address) + { + return AddressWidth != PointerSize.Bit32 || address.Value <= uint.MaxValue; + } +} + +/// Internal port for the Cheat Engine calls of the instruction domain. +/// +/// +/// Every Client call runs on Cheat Engine's main thread inside one dispatched callback: +/// asks CheatEngine.SDK for one Lua admission and runs the whole call under it, the +/// profile is observed once with , then the operation methods receive that same +/// profile. A refused admission is reported as a classified failure (LuaAdmission) without calling Cheat +/// Engine. +/// +/// +/// The operation methods return CheatEngine.SDK's own ; CheatEngine.SDK +/// checks the selected process before and after each Cheat Engine call. No method selects a process, changes +/// Cheat Engine's assembler mode or writes target memory. +/// +/// +internal interface IInstructionPort +{ + /// Runs under one CheatEngine.SDK Lua admission. + /// The public Client operation name, for an admission refusal. + /// The Client work of one call; it runs only when the SDK admitted the operation. + /// The classified admission refusal when not admitted. + /// when the SDK admitted the operation and ran. + public bool TryRunAdmitted(string operation, Action work, out CheatEngineFailure admissionFailure); + + /// Observes the selected target and its instruction profile (InstructionProfiles.TryObserveCurrent). + /// The observed profile when the status is Success; otherwise the default value. + /// The SDK status of the observation. + public InstructionOperationStatus ObserveProfile(out InstructionProfileObservation profile); + + /// Assembles one instruction into (InstructionAssembler.TryAssemble). + /// The profile observed for this call. + /// The instruction source. + /// The origin address sent to Cheat Engine. + /// The jump-encoding preference sent to Cheat Engine. + /// The range-check option sent to Cheat Engine. + /// The Client-owned buffer. + /// The number of bytes copied on success; zero otherwise. + /// The exact length of Cheat Engine's valid result; zero when it returned none. + /// The SDK status of the call. + public InstructionOperationStatus Assemble(InstructionProfileObservation profile, string instruction, + Address address, AssemblePreference preference, bool skipRangeCheck, Span destination, out int written, + out int requiredLength); + + /// Disassembles one instruction (InstructionDisassembler.TryDisassemble). + /// The profile observed for this call. + /// The address of the instruction. + /// The largest raw display line accepted before any text is decoded. + /// The copied columns on success; otherwise the default value. + /// The SDK status of the call. + public InstructionOperationStatus Disassemble(InstructionProfileObservation profile, Address address, + int maximumUtf8Bytes, out InstructionDisassembly disassembly); + + /// Gets the length of one instruction (InstructionNavigator.TryGetLength). + /// The profile observed for this call. + /// The address of the instruction. + /// The positive length on success; otherwise zero. + /// The SDK status of the call. + public InstructionOperationStatus GetLength(InstructionProfileObservation profile, Address address, + out int length); + + /// Gets Cheat Engine's estimated previous instruction (InstructionNavigator.TryGetPrevious). + /// The profile observed for this call. + /// The address that follows the instruction sought. + /// The estimated address on success; otherwise zero. + /// + /// The SDK status of the call; AddressExceedsProfileWidth for an input the Client already accepted is the + /// returned estimate. + /// + public InstructionOperationStatus GetPrevious(InstructionProfileObservation profile, Address address, + out Address previous); + + /// Reads the bytes of one instruction (TargetMemory.TryReadBytes with a copied count). + /// The address of the instruction. + /// The Client-owned buffer, exactly the instruction length. + /// The number of bytes CheatEngine.SDK verified and copied. + /// The SDK failure when the read did not complete. + /// only when every byte was copied. + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure); +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/InstructionMapping.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/InstructionMapping.cs new file mode 100644 index 0000000..f7f271d --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/InstructionMapping.cs @@ -0,0 +1,220 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// Where an was reported within one Client call. +internal enum InstructionCallPhase +{ + /// By the profile observation, before any instruction global of Cheat Engine was called. + ProfileObservation, + + /// By the first assembler, disassembler or navigator call of the Client call. + Operation, + + /// + /// By a later step of the Client call (the assembly retry, the byte read or the disassembly of + /// Disassemble), after an earlier instruction call of Cheat Engine returned. + /// + AfterEarlierCall +} + +/// +/// Maps every CheatEngine.SDK 2.0.0 to the Client result vocabulary, without +/// reading Cheat Engine's text. +/// +/// +/// +/// +/// SDK status +/// Client failure kind +/// +/// Successsuccess, no failure +/// InvalidProfileInvalidHostResult +/// AddressExceedsProfileWidthOperationRejected +/// TargetNotSelectedTargetNotAttached +/// TargetChangedTargetChanged +/// +/// DestinationTooSmall (after the one retry) and OutputTooLong +/// ResultLimitExceeded +/// +/// InstructionRejectedOperationRejected +/// GlobalUnavailableCapabilityUnavailable +/// LuaFailureLuaError +/// InvalidResultInvalidHostResult +/// UnsupportedTargetBackendUnsupported +/// +/// Unknown and any value this Client version does not know +/// IndeterminateHostResult, never a success +/// +/// +/// +/// The host effect follows the phase. A status of the profile observation is +/// : no instruction global was called. A status of the first +/// operation is for GlobalUnavailable and +/// AddressExceedsProfileWidth (both refused before the call), +/// for InstructionRejected (Cheat Engine's documented negative result), +/// for DestinationTooSmall and OutputTooLong (Cheat +/// Engine returned; the Client bound refused the copy), and otherwise. +/// A status of a later step follows the same rule, except that a refusal before its own call is +/// : an earlier instruction call of the same Client call already +/// returned, so the Client call did start Cheat Engine work. applies that rule +/// to a failed byte read. No instruction operation writes target memory. The mapping-totality tests fail when +/// the consumed SDK adds a status. +/// +/// +internal static class InstructionMapping +{ + /// Maps a status to its failure; for Success. + /// The public Client operation name. + /// The SDK status. + /// Where the status was reported. + /// The classified failure, or when the SDK reported success. + internal static CheatEngineFailure? ToFailure(string operation, InstructionOperationStatus status, + InstructionCallPhase phase) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + return status == InstructionOperationStatus.Success + ? null + : new CheatEngineFailure(ToFailureKind(status), operation, Describe(status), null, + ToHostEffect(status, phase)); + } + + /// Returns the Client failure kind of a status other than Success. + /// The SDK status. + /// + /// The failure kind; for an unrecognized value. + /// + internal static CheatEngineFailureKind ToFailureKind(InstructionOperationStatus status) + { + return status switch + { + InstructionOperationStatus.InvalidProfile => CheatEngineFailureKind.InvalidHostResult, + InstructionOperationStatus.AddressExceedsProfileWidth => CheatEngineFailureKind.OperationRejected, + InstructionOperationStatus.TargetNotSelected => CheatEngineFailureKind.TargetNotAttached, + InstructionOperationStatus.TargetChanged => CheatEngineFailureKind.TargetChanged, + InstructionOperationStatus.DestinationTooSmall => CheatEngineFailureKind.ResultLimitExceeded, + InstructionOperationStatus.OutputTooLong => CheatEngineFailureKind.ResultLimitExceeded, + InstructionOperationStatus.InstructionRejected => CheatEngineFailureKind.OperationRejected, + InstructionOperationStatus.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + InstructionOperationStatus.LuaFailure => CheatEngineFailureKind.LuaError, + InstructionOperationStatus.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + InstructionOperationStatus.UnsupportedTargetBackend => CheatEngineFailureKind.Unsupported, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Returns what a status establishes about Cheat Engine's work. + /// The SDK status. + /// Where the status was reported. + /// The host effect; unless the status proves more. + internal static CheatEngineHostEffect ToHostEffect(InstructionOperationStatus status, InstructionCallPhase phase) + { + if (phase == InstructionCallPhase.ProfileObservation) + { + return CheatEngineHostEffect.NotStarted; + } + + CheatEngineHostEffect effect = status switch + { + InstructionOperationStatus.GlobalUnavailable => CheatEngineHostEffect.NotStarted, + InstructionOperationStatus.AddressExceedsProfileWidth => CheatEngineHostEffect.NotStarted, + InstructionOperationStatus.InstructionRejected => CheatEngineHostEffect.NotApplied, + InstructionOperationStatus.DestinationTooSmall => CheatEngineHostEffect.Completed, + InstructionOperationStatus.OutputTooLong => CheatEngineHostEffect.Completed, + _ => CheatEngineHostEffect.Unknown + }; + return phase == InstructionCallPhase.AfterEarlierCall && effect == CheatEngineHostEffect.NotStarted + ? CheatEngineHostEffect.Completed + : effect; + } + + /// + /// Re-states the failure of a later step of a Client call, after an earlier instruction call of Cheat Engine + /// returned: a step refused before its own call is , never + /// . + /// + /// The failure of the later step, for example a failed byte read. + /// + /// The same failure, with in place of a not-started effect. + /// + internal static CheatEngineFailure AfterEarlierCall(CheatEngineFailure failure) + { + return failure.HostEffect == CheatEngineHostEffect.NotStarted + ? new CheatEngineFailure(failure.Kind, failure.Operation, failure.Message, failure.Exception, + CheatEngineHostEffect.Completed) + : failure; + } + + /// Creates the refusal of an address wider than the observed profile, before Cheat Engine is called. + /// The public Client operation name. + /// with . + internal static CheatEngineFailure AddressOutsideProfile(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + "The address does not fit the 32-bit instruction profile of the selected target; Cheat Engine was not called.", + null, CheatEngineHostEffect.NotStarted); + } + + /// Creates the failure of a previous-instruction estimate wider than the observed profile. + /// The public Client operation name. + /// with . + internal static CheatEngineFailure ReturnedAddressOutsideProfile(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + "Cheat Engine returned an address that does not fit the instruction profile of the selected target; it was " + + "not published.", null, CheatEngineHostEffect.Completed); + } + + /// Creates the failure of a result larger than the Client bound. + /// The public Client operation name. + /// The number of bytes Cheat Engine produced. + /// The Client bound in bytes. + /// with . + internal static CheatEngineFailure ResultExceedsLimit(string operation, int requiredLength, int limit) + { + return new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, operation, + $"The instruction takes {requiredLength} bytes, more than the configured limit of {limit} bytes; nothing was " + + "copied.", null, CheatEngineHostEffect.Completed); + } + + /// Creates the failure of a result CheatEngine.SDK reported as successful but outside its shape. + /// The public Client operation name. + /// The stable description of the violated shape. + /// with . + internal static CheatEngineFailure InvalidResult(string operation, string message) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, message, null, + CheatEngineHostEffect.Completed); + } + + /// Describes a status without naming an address, an instruction or a byte. + /// The SDK status. + /// A stable message. + internal static string Describe(InstructionOperationStatus status) + { + return status switch + { + InstructionOperationStatus.InvalidProfile => + "Cheat Engine reported contradictory or unsupported instruction-set facts for the selected target.", + InstructionOperationStatus.AddressExceedsProfileWidth => + "The address does not fit the instruction profile of the selected target.", + InstructionOperationStatus.TargetNotSelected => "Cheat Engine has no selected target process.", + InstructionOperationStatus.TargetChanged => + "Cheat Engine's selected target changed during the instruction operation; nothing was published.", + InstructionOperationStatus.DestinationTooSmall => + "The assembled instruction does not fit the Client's bounded buffer; nothing was copied.", + InstructionOperationStatus.OutputTooLong => + "The disassembled instruction text is longer than the configured limit; nothing was copied.", + InstructionOperationStatus.InstructionRejected => "Cheat Engine rejected the instruction.", + InstructionOperationStatus.GlobalUnavailable => + "A Cheat Engine instruction function is unavailable, so it was not called.", + InstructionOperationStatus.LuaFailure => "A protected Cheat Engine instruction call raised a Lua error.", + InstructionOperationStatus.InvalidResult => + "Cheat Engine returned an instruction result outside its documented shape.", + InstructionOperationStatus.UnsupportedTargetBackend => + "The selected target is a file opened as a process, which has no instruction profile.", + _ => "CheatEngine.SDK reported no recognized instruction outcome." + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/SdkAutoAssemblerPort.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/SdkAutoAssemblerPort.cs new file mode 100644 index 0000000..0d5d0bd --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/SdkAutoAssemblerPort.cs @@ -0,0 +1,117 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// +/// Production Auto Assembler port: CheatEngine.SDK 2.0.0 AutoAssemblerPatcher.TryApplyWithOutcome and +/// AutoAssemblerPatcher.TryCheck, behind the Client's Lua admission. +/// +/// +/// +/// This type is the only Client code that calls AutoAssemblerPatcher and the only code that holds an +/// AutoAssemblerPatch. The patch is handed to its Client owner through , so +/// a failure between the activation and the publication of the owner releases the patch exactly once, with the +/// release mapping of the lease (); an unconfirmed release +/// surfaces as an that AutoAssemblerClient maps. +/// +/// +/// No hosted test has a Cheat Engine process or Lua state, so its lines are excluded from the coverage metric; +/// TryContractTests proves that a detached runtime is refused before any Cheat Engine call. +/// +/// +internal sealed class SdkAutoAssemblerPort : IAutoAssemblerPort +{ + private SdkAutoAssemblerPort() + { + } + + /// Gets the stateless production port. + internal static SdkAutoAssemblerPort Instance + { + get; + } = new(); + + public bool TryApply(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerApplyFacts facts, out IAutoAssemblerPatchOwner? patch, out CheatEngineFailure admissionFailure) + { + facts = default; + patch = null; + if (!LuaAdmission.TryAcquire(operation, out LuaRuntimeOperation admitted, out admissionFailure)) + { + return false; + } + + using LuaRuntimeOperation admission = admitted; + AutoAssemblerApplyOutcome outcome = + AutoAssemblerPatcher.TryApplyWithOutcome(script, options, out AutoAssemblerPatch? applied); + facts = new AutoAssemblerApplyFacts(outcome.Kind, outcome.Effect, outcome.LuaStatus, outcome.HostText, + outcome.HostTextTruncated, outcome.HostWarnings, outcome.HostWarningsTruncated, + outcome.Compensation?.Status); + if (applied is not null) + { + patch = OwnershipHandoff.Adopt(applied, static owner => new SdkPatchOwner(owner), + static owner => MapUnpublishedRelease(owner.ReleaseWithTargetOutcome().Status)); + } + + return true; + } + + /// + /// Maps the one disable of a patch that Cheat Engine applied but the port could not publish, like every release + /// of its lease (). + /// + /// The status of CheatEngine.SDK's AutoAssemblerPatch.ReleaseWithTargetOutcome. + /// + /// The outcome reports: a disable that could not begin is the terminal + /// , never the retryable + /// , because CheatEngine.SDK consumed the disable information. + /// + internal static LeaseReleaseOutcome MapUnpublishedRelease(TargetReleaseStatus status) + { + return AutoAssemblerMapping.ToReleaseOutcome(status); + } + + public bool TryCheck(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerCheckFacts facts, out CheatEngineFailure admissionFailure) + { + facts = default; + if (!LuaAdmission.TryAcquire(operation, out LuaRuntimeOperation admitted, out admissionFailure)) + { + return false; + } + + using LuaRuntimeOperation admission = admitted; + AutoAssemblerCheckOutcome outcome = AutoAssemblerPatcher.TryCheck(script, true, options); + facts = new AutoAssemblerCheckFacts(outcome.Kind, outcome.LuaStatus, outcome.HostText, + outcome.HostTextTruncated); + return true; + } + + /// The Client view of CheatEngine.SDK's AutoAssemblerPatch. + /// + /// is ReleaseWithTargetOutcome: it consumes the disable information, validates the + /// captured target and runs [DISABLE] once. It never throws for a consumed owner here, because the lease + /// reads first under its release gate. + /// + private sealed class SdkPatchOwner(AutoAssemblerPatch patch) : IAutoAssemblerPatchOwner + { + public TargetProcessIncarnation TargetIncarnation => patch.TargetIncarnation; + + public bool IsEnabled => patch.IsEnabled; + + public bool IsConsumed => patch.IsDisposed; + + public bool RequiresManualRecovery => patch.RequiresManualRecovery; + + public TargetReleaseStatus LastReleaseStatus => patch.LastReleaseOutcome.Status; + + public TargetReleaseStatus Release() + { + return patch.ReleaseWithTargetOutcome().Status; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/SdkInstructionPort.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/SdkInstructionPort.cs new file mode 100644 index 0000000..2210431 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/SdkInstructionPort.cs @@ -0,0 +1,94 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// +/// Production instruction port: CheatEngine.SDK 2.0.0 InstructionProfiles, InstructionAssembler, +/// InstructionDisassembler and InstructionNavigator, plus the counted TargetMemory.TryReadBytes, +/// behind the Client's Lua admission. +/// +/// +/// +/// This type is the only Client code that calls CheatEngine.SDK's instruction APIs. It never calls the SDK's +/// two-argument assembler overload, so the encoding preference and the range-check option always reach Cheat +/// Engine, and it never reads the disassembler's byte column. +/// +/// +/// No hosted test has a Cheat Engine process or Lua state, so its lines are excluded from the coverage metric; +/// TryContractTests proves that a detached runtime is refused before any Cheat Engine call. +/// +/// +internal sealed class SdkInstructionPort : IInstructionPort +{ + private SdkInstructionPort() + { + } + + /// Gets the stateless production port. + internal static SdkInstructionPort Instance + { + get; + } = new(); + + public bool TryRunAdmitted(string operation, Action work, out CheatEngineFailure admissionFailure) + { + if (!LuaAdmission.TryAcquire(operation, out LuaRuntimeOperation admitted, out admissionFailure)) + { + return false; + } + + using LuaRuntimeOperation admission = admitted; + work(); + return true; + } + + public InstructionOperationStatus ObserveProfile(out InstructionProfileObservation profile) + { + InstructionOperationStatus status = InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile observed); + profile = status == InstructionOperationStatus.Success + ? new InstructionProfileObservation(observed, observed.Target, observed.Profile.Architecture, + observed.Profile.AddressWidth) + : default; + return status; + } + + public InstructionOperationStatus Assemble(InstructionProfileObservation profile, string instruction, + Address address, AssemblePreference preference, bool skipRangeCheck, Span destination, out int written, + out int requiredLength) + { + InstructionOperationStatus status = InstructionAssembler.TryAssemble(profile.Sdk, instruction, address, + preference, skipRangeCheck, destination, out InstructionAssembly assembly); + written = assembly.Written; + requiredLength = assembly.RequiredLength; + return status; + } + + public InstructionOperationStatus Disassemble(InstructionProfileObservation profile, Address address, + int maximumUtf8Bytes, out InstructionDisassembly disassembly) + { + return InstructionDisassembler.TryDisassemble(profile.Sdk, address, maximumUtf8Bytes, out disassembly, out _); + } + + public InstructionOperationStatus GetLength(InstructionProfileObservation profile, Address address, + out int length) + { + return InstructionNavigator.TryGetLength(profile.Sdk, address, out length); + } + + public InstructionOperationStatus GetPrevious(InstructionProfileObservation profile, Address address, + out Address previous) + { + return InstructionNavigator.TryGetPrevious(profile.Sdk, address, out previous); + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + return TargetMemory.TryReadBytes(address, destination, out written, out failure); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs deleted file mode 100644 index 5c18600..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Assembly; -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Results; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Core.Domains.Assembly; - -/// Preserves the assembly and patch surface until Auto Assembler ownership passes its live-host gate. -internal sealed class UnavailableAssemblyClient : IAssemblyClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableAssemblyClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - instruction = default; - failure = CreateFailure("Assembly.Disassemble", cancellationToken); - return false; - } - - public AssemblyInstructionSnapshot Disassemble(Address address, CancellationToken cancellationToken = default) - { - _ = TryDisassemble(address, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryGetInstructionSize(Address address, out int size, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - size = default; - failure = CreateFailure("Assembly.GetInstructionSize", cancellationToken); - return false; - } - - public int GetInstructionSize(Address address, CancellationToken cancellationToken = default) - { - _ = TryGetInstructionSize(address, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryGetPreviousInstruction(Address address, out Address previousAddress, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - previousAddress = default; - failure = CreateFailure("Assembly.GetPreviousInstruction", cancellationToken); - return false; - } - - public Address GetPreviousInstruction(Address address, CancellationToken cancellationToken = default) - { - _ = TryGetPreviousInstruction(address, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw
(failure); - } - - public bool TryGetComment(Address address, [NotNullWhen(true)] out string? comment, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - comment = null; - failure = CreateFailure("Assembly.GetComment", cancellationToken); - return false; - } - - public string GetComment(Address address, CancellationToken cancellationToken = default) - { - _ = TryGetComment(address, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryAssemble(AssemblyInstructionRequest request, out ImmutableArray bytes, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - bytes = default; - failure = CreateFailure("Assembly.Assemble", cancellationToken); - return false; - } - - public ImmutableArray Assemble(AssemblyInstructionRequest request, - CancellationToken cancellationToken = default) - { - _ = TryAssemble(request, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw>(failure); - } - - public bool TryApplyPatch(AutoAssemblerScript script, [NotNullWhen(true)] out IAutoAssemblerPatchLease? lease, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - { - lease = null; - failure = CreateFailure("Assembly.ApplyPatch", cancellationToken); - return false; - } - - public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, - CancellationToken cancellationToken = default) - { - _ = TryApplyPatch(script, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) - { - return UnavailableCapabilityFailure.Create(_lifetime, "Assembly, disassembly, and Auto Assembler patches", - operation, - cancellationToken); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/ClientCapabilityCatalog.cs b/libs/CheatEngine.Client.Core/Domains/ClientCapabilityCatalog.cs new file mode 100644 index 0000000..28e84cc --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ClientCapabilityCatalog.cs @@ -0,0 +1,132 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Where the policy gate of a capability comes from. +internal enum CapabilityPolicySource +{ + /// The capability needs no activation opt-in: the policy gate is satisfied. + NotRequired, + + /// The policy gate follows the activation's EnableUnsafeLuaExecution opt-in. + UnsafeLuaExecutionOptIn, + + /// The policy gate follows the activation's EnableAutoAssemblerPatches opt-in. + AutoAssemblerPatchesOptIn +} + +/// Where the host gate of a capability comes from. +internal enum CapabilityHostSource +{ + /// + /// The runtime snapshot does not probe every host primitive the capability needs: the gate stays unknown. + /// + NotProbed, + + /// + /// The host gate is CheatEngine.SDK's observation of the selected-process primitive (Process.Current): + /// its entry in the SDK runtime snapshot, or the status of the target observation when the SDK produced none. + /// + SdkSelectedProcess +} + +/// One row of . +/// The Client capability. +/// Where the policy gate comes from. +/// Where the host gate comes from. +/// +/// The live qualification scenarios that must pass, none waived, before the qualification gate is satisfied +/// (HostQualificationGate); empty when the capability can never be qualified, so its qualification gate stays +/// unknown. +/// +internal sealed record ClientCapabilityDescriptor( + ClientCapabilityId Id, + CapabilityPolicySource Policy, + CapabilityHostSource Host, + ImmutableArray RequiredScenarios) +{ + /// + /// Gets the diagnostic id of the [Experimental] attribute on the capability's public API (for example + /// CECLIENT5001), or when the API is stable. The READMEs label the capability + /// "Operational adapter, experimental (id)" (CapabilityDocumentationTests). + /// + internal string? ExperimentalDiagnosticId + { + get; + init; + } +} + +/// The one description of every Client capability, in the order the runtime snapshot reports them. +/// +/// +/// composes the evidence of each capability from its row: the source of the policy +/// gate and of the host gate, and the experimental id that the implementation gate names. Every capability +/// composes an operational adapter, so its implementation gate is satisfied. The package and lifetime gates are +/// the same for every capability. The required scenarios are the live qualification scenarios the qualification +/// gate requires: HostQualificationGate satisfies it only from the host evidence this build embeds +/// (HostQualificationEvidence), which is empty until a live run is recorded. +/// +/// +/// Each capability has one row, and a lot changes only its own row. The capability tables of the READMEs follow +/// the experimental ids and the required scenarios (CapabilityDocumentationTests), and +/// ClientCapabilityCatalogTests proves that every has exactly one row +/// and that every required scenario exists in the live scenario catalog of the runner. +/// +/// +internal static class ClientCapabilityCatalog +{ + /// Gets every capability row, in snapshot order. + internal static ImmutableArray Entries + { + get; + } = + [ + Entry(ClientCapabilityId.ProcessSelection, + CapabilityPolicySource.NotRequired, CapabilityHostSource.SdkSelectedProcess, "Q30.a", "Q31", "Q32"), + Entry(ClientCapabilityId.TypedMemory, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q20", "Q21", "Q33"), + Entry(ClientCapabilityId.PatternScanning, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q27", "Q28", "Q29"), + Entry(ClientCapabilityId.ValueScanning, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q25", "Q26") with + { + ExperimentalDiagnosticId = "CECLIENT5001" + }, + Entry(ClientCapabilityId.Inspection, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q16.b", "Q28"), + Entry(ClientCapabilityId.Tables, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q34"), + Entry(ClientCapabilityId.ProtectedLua, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q05", "Q16", "Q19"), + // Arbitrary Lua is never qualified: no scenario, so its qualification gate stays unknown. + Entry(ClientCapabilityId.UnsafeLuaExecution, + CapabilityPolicySource.UnsafeLuaExecutionOptIn, CapabilityHostSource.NotProbed), + Entry(ClientCapabilityId.Allocations, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q30.a") with + { + ExperimentalDiagnosticId = "CECLIENT5002" + }, + // Experimental (CECLIENT5003); Q32 covers the instruction profile of the x64 and the x86 target. + Entry(ClientCapabilityId.Assembly, + CapabilityPolicySource.NotRequired, CapabilityHostSource.NotProbed, "Q32") with + { + ExperimentalDiagnosticId = "CECLIENT5003" + }, + // Experimental (CECLIENT5004) and registered only by the EnableAutoAssemblerPatches opt-in; Q44 is the policy + // refusal without it. + Entry(ClientCapabilityId.AutoAssemblerPatches, + CapabilityPolicySource.AutoAssemblerPatchesOptIn, CapabilityHostSource.NotProbed, "Q35", "Q44") with + { + ExperimentalDiagnosticId = "CECLIENT5004" + } + ]; + + private static ClientCapabilityDescriptor Entry(ClientCapabilityId id, CapabilityPolicySource policy, + CapabilityHostSource host, params string[] requiredScenarios) + { + return new ClientCapabilityDescriptor(id, policy, host, [.. requiredScenarios]); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs b/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs deleted file mode 100644 index 72bdf78..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.Dbvm; - -/// Observes no inferred DBVM state and never initializes DBVM before its explicit live-host gate passes. -internal sealed class UnavailableDbvmClient : IDbvmClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableDbvmClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - status = default; - failure = CreateFailure("Dbvm.GetStatus", cancellationToken); - return false; - } - - public DbvmStatusSnapshot GetStatus(CancellationToken cancellationToken = default) - { - _ = TryGetStatus(out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryInitialize(DbvmInitializationRequest request, out DbvmStatusSnapshot status, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - { - status = default; - failure = CreateFailure("Dbvm.Initialize", cancellationToken); - return false; - } - - public DbvmStatusSnapshot Initialize(DbvmInitializationRequest request, - CancellationToken cancellationToken = default) - { - _ = TryInitialize(request, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryRegisterWatch(DbvmWatchRequest request, DbvmWatchHandler handler, EventStreamOptions streamOptions, - [NotNullWhen(true)] out IDbvmWatchLease? lease, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(handler); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); - lease = null; - failure = CreateFailure("Dbvm.RegisterWatch", cancellationToken); - return false; - } - - public IDbvmWatchLease RegisterWatch(DbvmWatchRequest request, DbvmWatchHandler handler, - EventStreamOptions streamOptions, CancellationToken cancellationToken = default) - { - _ = TryRegisterWatch(request, handler, streamOptions, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) - { - return UnavailableCapabilityFailure.Create(_lifetime, "DBVM observation, explicit initialization, and watches", - operation, - cancellationToken); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs b/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs deleted file mode 100644 index d7313f3..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Debugger; -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.Debugger; - -/// Preserves copied breakpoint semantics until debugger callback ownership and reactivation pass a live gate. -internal sealed class UnavailableDebuggerClient : IDebuggerClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableDebuggerClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryRegisterBreakpoint(BreakpointRequest request, BreakpointHandler handler, - EventStreamOptions streamOptions, - [NotNullWhen(true)] out IBreakpointLease? lease, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(handler); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); - lease = null; - failure = UnavailableCapabilityFailure.Create(_lifetime, "Debugger breakpoints", "Debugger.RegisterBreakpoint", - cancellationToken); - return false; - } - - public IBreakpointLease RegisterBreakpoint(BreakpointRequest request, BreakpointHandler handler, - EventStreamOptions streamOptions, CancellationToken cancellationToken = default) - { - _ = TryRegisterBreakpoint(request, handler, streamOptions, out _, out CheatEngineFailure failure, - cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs b/libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs deleted file mode 100644 index 21fb5c2..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs +++ /dev/null @@ -1,510 +0,0 @@ -using System.Runtime.CompilerServices; - -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Core.Domains.Events; - -/// -/// Provides a bounded handoff from a Cheat Engine callback to one asynchronous observation consumer. Callback -/// admission does not wait for reader consumption, but it does take a short lock and is not lock-free. -/// Reader continuations always run asynchronously. -/// -/// The copied event type. -internal sealed class BoundedEventStream : IAsyncEnumerable, IDisposable -{ - private readonly T[] _buffer; - private readonly object _gate = new(); - private readonly EventStreamOverflowPolicy _overflowPolicy; - private Enumerator? _activeReader; - private bool _completed; - private Exception? _completionError; - private int _count; - private bool _disposed; - private bool _isAdmissionOpen = true; - private long _lostCount; - private PendingRead? _pendingRead; - private int _readIndex; - private int _writeIndex; - - /// Initializes a stream with the supplied bounded-buffer policy. - internal BoundedEventStream(EventStreamOptions options) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(options.Capacity); - if (!Enum.IsDefined(options.OverflowPolicy)) - { - throw new ArgumentOutOfRangeException(nameof(options)); - } - - _buffer = new T[options.Capacity]; - _overflowPolicy = options.OverflowPolicy; - } - - /// Gets the number of events intentionally not delivered because the bounded buffer was full. - internal long LostCount - { - get - { - lock (_gate) - { - return _lostCount; - } - } - } - - /// Gets whether callbacks can still publish into the stream. - internal bool IsAdmissionOpen - { - get - { - lock (_gate) - { - return _isAdmissionOpen; - } - } - } - - /// Gets whether no further events will be admitted. - internal bool IsCompleted - { - get - { - lock (_gate) - { - return _completed; - } - } - } - - /// - /// Returns the sole active asynchronous enumerator. A second concurrent reader is rejected rather than becoming - /// an unbounded competing subscription. - /// - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - lock (_gate) - { - if (_activeReader is not null) - { - throw new InvalidOperationException( - "A bounded Client event stream supports only one active observation reader."); - } - - Enumerator enumerator = new(this, cancellationToken); - _activeReader = enumerator; - return enumerator; - } - } - - /// Closes admission and discards buffered events during deterministic subscription teardown. - public void Dispose() - { - PendingRead? pendingRead; - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - _isAdmissionOpen = false; - _completed = true; - _activeReader = null; - DiscardBufferedEvents(); - pendingRead = DetachPendingRead(); - } - - CompletePendingRead(pendingRead, ReadResult.End); - } - - /// - /// Attempts to publish a copied callback event without waiting for an asynchronous consumer. A - /// result means admission has closed or the selected overflow policy terminated the - /// subscription. - /// - internal bool TryPublish(T value) - { - Exception? completionError = null; - PendingRead? pendingRead; - lock (_gate) - { - if (!_isAdmissionOpen) - { - return false; - } - - pendingRead = DetachPendingRead(); - if (pendingRead is null) - { - if (_count < _buffer.Length) - { - Enqueue(value); - return true; - } - - switch (_overflowPolicy) - { - case EventStreamOverflowPolicy.DropOldest: - _buffer[_writeIndex] = value; - _writeIndex = NextIndex(_writeIndex); - _readIndex = _writeIndex; - _lostCount++; - return true; - - case EventStreamOverflowPolicy.DropNewest: - _lostCount++; - return true; - - case EventStreamOverflowPolicy.FailSubscription: - _lostCount++; - completionError = new InvalidOperationException( - "The bounded callback stream overflowed and the subscription was closed."); - pendingRead = CompleteCore(completionError, true); - break; - - default: - throw new InvalidOperationException( - "The bounded callback stream has an unknown overflow policy."); - } - } - } - - if (completionError is null) - { - CompletePendingRead(pendingRead, new ReadResult(value)); - return true; - } - - FailPendingRead(pendingRead, completionError); - return false; - } - - /// Closes admission and lets readers drain the copied events already accepted by the stream. - internal void Complete() - { - PendingRead? pendingRead; - lock (_gate) - { - pendingRead = CompleteCore(null, false); - } - - CompletePendingRead(pendingRead, ReadResult.End); - } - - /// Closes callback admission without completing existing readers until the host callback is neutralized. - internal void CloseAdmission() - { - lock (_gate) - { - _isAdmissionOpen = false; - } - } - - /// Closes admission and completes all consumers with the supplied failure. - internal void Complete(Exception error) - { - ArgumentNullException.ThrowIfNull(error); - PendingRead? pendingRead; - lock (_gate) - { - pendingRead = CompleteCore(error, true); - } - - FailPendingRead(pendingRead, error); - } - - private ValueTask ReadAsync(Enumerator reader, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return ValueTask.FromCanceled(cancellationToken); - } - - PendingRead? pendingRead; - lock (_gate) - { - if (!ReferenceEquals(_activeReader, reader)) - { - return ValueTask.FromResult(ReadResult.End); - } - - if (_count != 0) - { - return ValueTask.FromResult(new ReadResult(Dequeue())); - } - - if (_completionError is not null) - { - return ValueTask.FromException(_completionError); - } - - if (_completed) - { - return ValueTask.FromResult(ReadResult.End); - } - - pendingRead = new PendingRead(this, reader, cancellationToken); - _pendingRead = pendingRead; - } - - pendingRead.RegisterCancellation(); - return new ValueTask(pendingRead.Task); - } - - private void CancelPendingRead(PendingRead pendingRead, CancellationToken cancellationToken) - { - lock (_gate) - { - if (!ReferenceEquals(_pendingRead, pendingRead) || !pendingRead.TryBeginCompletion()) - { - return; - } - - _pendingRead = null; - if (ReferenceEquals(_activeReader, pendingRead.Reader)) - { - _activeReader = null; - } - } - - pendingRead.Cancel(cancellationToken); - } - - private void ReleaseReader(Enumerator reader) - { - PendingRead? pendingRead = null; - lock (_gate) - { - if (!ReferenceEquals(_activeReader, reader)) - { - return; - } - - _activeReader = null; - if (ReferenceEquals(_pendingRead?.Reader, reader)) - { - pendingRead = DetachPendingRead(); - } - } - - CompletePendingRead(pendingRead, ReadResult.End); - } - - private PendingRead? DetachPendingRead() - { - PendingRead? pendingRead = _pendingRead; - _pendingRead = null; - return pendingRead is not null && pendingRead.TryBeginCompletion() ? pendingRead : null; - } - - private PendingRead? CompleteCore(Exception? error, bool clearBufferedEvents) - { - if (_completed) - { - return null; - } - - _isAdmissionOpen = false; - _completed = true; - _completionError = error; - - if (clearBufferedEvents) - { - DiscardBufferedEvents(); - } - - return DetachPendingRead(); - } - - private void Enqueue(T value) - { - _buffer[_writeIndex] = value; - _writeIndex = NextIndex(_writeIndex); - _count++; - } - - private T Dequeue() - { - T value = _buffer[_readIndex]; - _buffer[_readIndex] = default!; - _readIndex = NextIndex(_readIndex); - _count--; - return value; - } - - private void ClearBuffer() - { - if (RuntimeHelpers.IsReferenceOrContainsReferences()) - { - Array.Clear(_buffer); - } - - _readIndex = 0; - _writeIndex = 0; - _count = 0; - } - - private void DiscardBufferedEvents() - { - _lostCount += _count; - ClearBuffer(); - } - - private int NextIndex(int index) - { - return index == _buffer.Length - 1 ? 0 : index + 1; - } - - private static void CompletePendingRead(PendingRead? pendingRead, ReadResult result) - { - pendingRead?.Complete(result); - } - - private static void FailPendingRead(PendingRead? pendingRead, Exception error) - { - pendingRead?.Fail(error); - } - - private readonly record struct ReadResult(bool HasValue, T? Value) - { - internal ReadResult(T value) - : this(true, value) - { - } - - internal static ReadResult End => new(false, default); - } - - private sealed class PendingRead( - BoundedEventStream owner, - Enumerator reader, - CancellationToken cancellationToken) - { - private readonly CancellationToken _cancellationToken = cancellationToken; - private readonly BoundedEventStream _owner = owner; - - private readonly TaskCompletionSource _source = new( - TaskCreationOptions.RunContinuationsAsynchronously); - - private int _completionStarted; - private CancellationTokenRegistration _registration; - - internal Enumerator Reader => reader; - - internal Task Task => _source.Task; - - internal bool TryBeginCompletion() - { - return Interlocked.CompareExchange(ref _completionStarted, 1, 0) == 0; - } - - internal void RegisterCancellation() - { - if (!_cancellationToken.CanBeCanceled) - { - return; - } - - _registration = _cancellationToken.UnsafeRegister(static state => - { - PendingRead pendingRead = (PendingRead) state!; - pendingRead._owner.CancelPendingRead(pendingRead, pendingRead._cancellationToken); - }, this); - - if (Volatile.Read(ref _completionStarted) != 0) - { - _registration.Unregister(); - } - } - - internal void Complete(ReadResult result) - { - _registration.Unregister(); - _source.TrySetResult(result); - } - - internal void Cancel(CancellationToken cancellationToken) - { - _registration.Unregister(); - _source.TrySetCanceled(cancellationToken); - } - - internal void Fail(Exception error) - { - _registration.Unregister(); - _source.TrySetException(error); - } - } - - private sealed class Enumerator(BoundedEventStream owner, CancellationToken cancellationToken) - : IAsyncEnumerator - { - private readonly CancellationToken _cancellationToken = cancellationToken; - private readonly BoundedEventStream _owner = owner; - private int _completed; - private int _disposed; - private int _moveNextInProgress; - - public T Current - { - get; - private set; - } = default!; - - public async ValueTask MoveNextAsync() - { - if (Volatile.Read(ref _disposed) != 0 || Volatile.Read(ref _completed) != 0) - { - return false; - } - - if (Interlocked.Exchange(ref _moveNextInProgress, 1) != 0) - { - throw new InvalidOperationException( - "Concurrent MoveNextAsync calls are not supported for one event-stream enumerator."); - } - - try - { - ReadResult result = await _owner.ReadAsync(this, _cancellationToken).ConfigureAwait(false); - if (!result.HasValue) - { - Current = default!; - CompleteReader(); - return false; - } - - Current = result.Value!; - return true; - } - catch - { - CompleteReader(); - throw; - } - finally - { - Volatile.Write(ref _moveNextInProgress, 0); - } - } - - public ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) == 0) - { - Current = default!; - CompleteReader(); - } - - return ValueTask.CompletedTask; - } - - private void CompleteReader() - { - if (Interlocked.Exchange(ref _completed, 1) == 0) - { - _owner.ReleaseReader(this); - } - } - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs b/libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs deleted file mode 100644 index 17e4c03..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Runtime.ExceptionServices; - -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Core.Domains.Events; - -/// -/// Owns the release sequence for one host callback and its copied, bounded Client event stream. The sequence is -/// deliberately ordered so that the host can no longer enqueue an event before consumers observe completion. -/// -/// The copied event type admitted by the callback. -internal sealed class EventStreamLease( - BoundedEventStream stream, - Action neutralizeCallback, - Action releaseHostRegistration, - Action>? untrack = null) : IEventStreamLease -{ - private readonly Lock _gate = new(); - - private readonly Action _neutralizeCallback = - neutralizeCallback ?? throw new ArgumentNullException(nameof(neutralizeCallback)); - - private readonly Action _releaseHostRegistration = releaseHostRegistration ?? - throw new ArgumentNullException(nameof(releaseHostRegistration)); - - private readonly BoundedEventStream _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - private readonly Action>? _untrack = untrack; - private int _disposing; - private int _released; - - /// - public IAsyncEnumerable Events => _stream; - - /// - public long DroppedEventCount => _stream.LostCount; - - /// - public bool IsReleased => Volatile.Read(ref _released) != 0; - - /// - /// Releases the subscription in lifecycle order: stop admission, neutralize the host callback, complete the - /// stream, release the host registration, and finally untrack the lease. - /// - public void Dispose() - { - lock (_gate) - { - if (Volatile.Read(ref _released) != 0 || Volatile.Read(ref _disposing) != 0) - { - return; - } - - Volatile.Write(ref _disposing, 1); - } - - Exception? firstFailure = null; - firstFailure = RunCleanupStep(_stream.CloseAdmission, firstFailure); - firstFailure = RunCleanupStep(_neutralizeCallback, firstFailure); - firstFailure = RunCleanupStep(_stream.Complete, firstFailure); - firstFailure = RunCleanupStep(_releaseHostRegistration, firstFailure); - firstFailure = RunCleanupStep(Untrack, firstFailure); - Volatile.Write(ref _released, 1); - Volatile.Write(ref _disposing, 0); - - if (firstFailure is not null) - { - ExceptionDispatchInfo.Capture(firstFailure).Throw(); - } - } - - /// Attempts to admit one copied event from the host callback without waiting for a consumer. - internal bool TryPublish(TEvent value) - { - return _stream.TryPublish(value); - } - - private void Untrack() - { - _untrack?.Invoke(this); - } - - private static Exception? RunCleanupStep(Action cleanup, Exception? firstFailure) - { - try - { - cleanup(); - } - catch (Exception exception) - { - return firstFailure ?? exception; - } - - return firstFailure; - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs b/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs deleted file mode 100644 index 37c55f7..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Diagnostics; - -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.Events; - -/// Creates the uniform capability-gated result used by high-level domains awaiting their live-host gate. -internal static class UnavailableCapabilityFailure -{ - internal static CheatEngineFailure Create(string capabilityName, string operation, - CancellationToken cancellationToken) - { - return Create(null, capabilityName, operation, cancellationToken); - } - - internal static CheatEngineFailure Create(CoreLifetime? lifetime, string capabilityName, string operation, - CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(capabilityName); - ArgumentException.ThrowIfNullOrWhiteSpace(operation); - lifetime?.ThrowIfInactive(operation); - - return cancellationToken.IsCancellationRequested - ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, - "The operation was cancelled before Cheat Engine work began.") - : new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, - $"{capabilityName} is unavailable because this Client package currently composes an unavailable adapter. " + - "Promotion also requires its ownership, thread-affinity, cleanup, disable, re-enable, and target-change " + - "behavior to pass the required Cheat Engine 7.7 x64 live gate."); - } - - internal static T Throw(CheatEngineFailure failure) - { - failure.Throw(); - throw new UnreachableException(); - } - - internal static void Throw(CheatEngineFailure failure) - { - failure.Throw(); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs b/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs deleted file mode 100644 index 596a1c0..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs +++ /dev/null @@ -1,51 +0,0 @@ -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.Hashing; - -/// Preserves the separate file and target-memory hash contracts until their live-host gates are complete. -internal sealed class UnavailableHashingClient : IHashingClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableHashingClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryHashMemory(MemoryHashRequest request, out HashDigest digest, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - digest = default; - failure = CreateFailure("Hashing.HashMemory", cancellationToken); - return false; - } - - public HashDigest HashMemory(MemoryHashRequest request, CancellationToken cancellationToken = default) - { - _ = TryHashMemory(request, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryHashFile(FileHashRequest request, out HashDigest digest, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - digest = default; - failure = CreateFailure("Hashing.HashFile", cancellationToken); - return false; - } - - public HashDigest HashFile(FileHashRequest request, CancellationToken cancellationToken = default) - { - _ = TryHashFile(request, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) - { - return UnavailableCapabilityFailure.Create(_lifetime, "Target-memory and file hashing", operation, - cancellationToken); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs b/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs deleted file mode 100644 index 910fa8a..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Events; -using CheatEngine.Client.Hotkeys; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.Hotkeys; - -/// Preserves copied hotkey semantics until host callback lifecycle behavior passes the live-host gate. -internal sealed class UnavailableHotkeyClient : IHotkeyClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableHotkeyClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryRegister(HotkeyRegistration registration, HotkeyHandler handler, EventStreamOptions streamOptions, - [NotNullWhen(true)] out IHotkeyLease? lease, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(handler); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); - lease = null; - failure = UnavailableCapabilityFailure.Create(_lifetime, "Hotkeys", "Hotkeys.Register", cancellationToken); - return false; - } - - public IHotkeyLease Register(HotkeyRegistration registration, HotkeyHandler handler, - EventStreamOptions streamOptions, - CancellationToken cancellationToken = default) - { - _ = TryRegister(registration, handler, streamOptions, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs b/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs index d0d3992..954f309 100644 --- a/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs +++ b/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs @@ -1,11 +1,21 @@ using System.Diagnostics.CodeAnalysis; +using CheatEngine.SDK.Engine.Targets; + namespace CheatEngine.Client.Core.Domains; /// Internal, activation-thread-owned view of an SDK AOB result list. -internal interface IAobMatchList : IDisposable +internal interface IAobMatchList { public bool TryGetCount(out int count); public bool TryGetItem(int index, [NotNullWhen(true)] out string? value); + + /// Releases the Cheat Engine list once and reports what happened (Owned<T>.ReleaseWithOutcome). + /// + /// The SDK release status. Only confirms the release; the list is + /// consumed on every path and is never released again. + /// + /// The SDK release never throws; an implementation must not either. + public TargetReleaseStatus Release(); } diff --git a/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs b/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs index 3cf27a5..6ddded7 100644 --- a/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs @@ -1,15 +1,40 @@ -using System.Diagnostics.CodeAnalysis; - using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; /// Internal adapter boundary that copies SDK-owned AOB results before Client materializes them. internal interface IAobScanPort { - public AobScanHostStatus TryScan(string pattern, AobScanOptions options, - [NotNullWhen(true)] out IAobMatchList? matches); + /// Runs one global AOBScan (AobScanner.TryScanOutcome with its target context). + /// The normalized pattern text. + /// The SDK protection and alignment arguments. + /// + /// The owned result list whenever the SDK handed one out, whatever the outcome; otherwise. + /// The caller releases a returned list exactly once. + /// + /// The copied SDK outcome and target observations. + /// + /// The SDK handed out a list that could not be published, and its release was not confirmed. + /// + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches); + + /// + /// Runs one bounded, exhaustive scan (AobScanner.TryScanWithinBounds, the overload without a call + /// deadline) and copies in-bounds addresses into . + /// + /// The normalized pattern text. + /// The Cheat Engine work limit [Start, Stop). + /// The SDK protection and alignment arguments. + /// The materialization limit; written only for a successful outcome. + /// Observed by the SDK between Cheat Engine calls. + /// The copied SDK result; the session is already released. + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken); + + /// Observes what identifies Cheat Engine's selected target (TargetSelection.ObserveCurrent). + public TargetSelectionFacts ObserveSelection(); public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written); } diff --git a/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs b/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs index f40f6ac..ffe166b 100644 --- a/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs @@ -1,5 +1,7 @@ +using CheatEngine.Client.Inspection; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Domains; @@ -18,12 +20,18 @@ internal interface IInspectionPort public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol); - public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, out Address address); - public bool TryResolveName(nuint address, out string? name); - - public void RegisterSymbol(string name, nuint address, bool doNotSave); - - public void UnregisterSymbol(string name); + /// Gets Cheat Engine's formatted name for a target address (SymbolRegistry.TryGetName). + public LuaOperationStatus TryGetName(Address address, out string? name); + + /// + /// Registers a name through CheatEngine.SDK's symbol ownership coordinator (SymbolRegistry.TryRegisterOwned). + /// + /// + /// Cheat Engine registered the name but the SDK could not publish its lease; the SDK compensated once. + /// + public SymbolRegistrationAttempt TryRegisterOwned(SymbolName name, Address address, + SymbolRegistrationOptions options); } diff --git a/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs b/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs index af16c63..5a65d23 100644 --- a/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs @@ -1,7 +1,9 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; -using CheatEngine.Client.Core.Infrastructure; using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; @@ -9,33 +11,81 @@ namespace CheatEngine.Client.Core.Domains; /// Provides the SDK-backed operations available to a scoped application memory codec. /// /// The port is internal so Core tests can prove that an expired context never reaches SDK statics. It is not a -/// replacement public memory abstraction. +/// replacement public memory abstraction. Every access reports the SDK's own , +/// never its text; classifies it. Its target facts are the read-only +/// CheatEngine.SDK observations of : every pointer access passes the target +/// bitness they report to the width-qualified SDK overload, never the plugin's own process width (CESDK1020). /// -internal interface IMemoryCodecContextPort +internal interface IMemoryCodecContextPort : ITargetObservationPort { - public bool IsTarget64Bit(); + /// + /// Tries to fill and reports how many bytes CheatEngine.SDK verified and copied + /// (TargetMemory.TryReadBytes with a copied count): the confirmed contiguous prefix on + /// , every byte on success. + /// + public bool TryReadBytes(Address address, Span destination, out int written, out MemoryAccessFailure failure); + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure); + + /// Tries one built-in scalar read after the Core client has admitted the operation. + /// Pointers never take this route: they go through with the observed width. + public bool TryReadPrimitive(Address address, out T value, out MemoryAccessFailure failure) + { + return SdkMemoryPrimitivePort.TryRead(address, out value, out failure); + } - public bool TryReadBytes(Address address, Span destination, out string? failure); + /// Tries one built-in scalar write after the Core client has admitted the operation. + /// Pointers never take this route: they go through with the observed width. + public bool TryWritePrimitive(Address address, T value, out MemoryAccessFailure failure) + { + return SdkMemoryPrimitivePort.TryWrite(address, value, out failure); + } - public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure); + /// + /// Tries one pointer read qualified by the observed target bitness (TargetMemory.TryReadPointer with a + /// ): the SDK refuses an unknown width, and a returned value wider than the width. + /// + public bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, + out MemoryAccessFailure failure) + { + return TargetMemory.TryReadPointer(address, pointerSize, out value, out failure); + } - /// Tries one built-in primitive read after the Core client has admitted the operation. - public bool TryReadPrimitive(Address address, out T value, out string? failure) + /// + /// Tries one pointer write qualified by the observed target bitness (TargetMemory.TryWritePointer with a + /// ): the SDK refuses an unknown width, and a value wider than the width, before Cheat + /// Engine is called. + /// + public bool TryWritePointer(Address address, Address value, PointerSize pointerSize, + out MemoryAccessFailure failure) { - return SdkMemoryPrimitivePort.TryRead(address, out value, out failure); + return TargetMemory.TryWritePointer(address, value, pointerSize, out failure); } - /// Tries one built-in primitive write after the Core client has admitted the operation. - public bool TryWritePrimitive(Address address, T value, out string? failure) + /// Tries one bounded string read after the Core client has admitted the operation. + /// is passed unchanged as Cheat Engine's readString limit. + public bool TryReadString(Address address, int maximumLength, bool wideCharacter, out string? value, + out MemoryAccessFailure failure) { - return SdkMemoryPrimitivePort.TryWrite(address, value, out failure); + return TargetMemory.TryReadString(address, maximumLength, wideCharacter, out value, out failure); + } + + /// Tries one string write after the Core client has admitted the operation. + public bool TryWriteString(Address address, ReadOnlySpan value, bool wideCharacter, + out MemoryAccessFailure failure) + { + return TargetMemory.TryWriteString(address, value, wideCharacter, out failure); } } -/// Maps the Core's supported primitive set to the SDK while retaining an injectable port boundary. +/// Maps the Core's supported scalar set to the SDK while retaining an injectable port boundary. +/// +/// The Core client admits the type before it calls the port and routes to the width-qualified +/// pointer overloads, so any other type reaching this class is a Client defect, never a host outcome. +/// internal static class SdkMemoryPrimitivePort { - internal static bool TryRead(Address address, out T value, out string? failure) + internal static bool TryRead(Address address, out T value, out MemoryAccessFailure failure) { if (typeof(T) == typeof(byte)) { @@ -87,17 +137,10 @@ internal static bool TryRead(Address address, out T value, out string? failur return TryRead(TargetMemory.TryReadDouble, address, out value, out failure); } - if (typeof(T) == typeof(Address)) - { - return TryRead(TargetMemory.TryReadPointer, address, out value, out failure); - } - - value = default!; - failure = $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type."; - return false; + throw NotAScalar(); } - internal static bool TryWrite(Address address, T value, out string? failure) + internal static bool TryWrite(Address address, T value, out MemoryAccessFailure failure) { if (typeof(T) == typeof(byte)) { @@ -149,40 +192,33 @@ internal static bool TryWrite(Address address, T value, out string? failure) return TryWrite(TargetMemory.TryWriteDouble, address, value, out failure); } - if (typeof(T) == typeof(Address)) - { - return TryWrite(TargetMemory.TryWritePointer, address, value, out failure); - } + throw NotAScalar(); + } - failure = $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type."; - return false; + private static UnreachableException NotAScalar() + { + return new UnreachableException( + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client scalar; the Core client admits the type first."); } - private static bool TryRead(Reader reader, Address address, out T value, out string? failure) + private static bool TryRead(Reader reader, Address address, out T value, + out MemoryAccessFailure failure) { - if (reader(address, out TValue readValue, out MemoryAccessFailure sdkFailure)) + if (reader(address, out TValue readValue, out failure)) { value = Unsafe.As(ref readValue); - failure = null; return true; } value = default!; - failure = sdkFailure.ToString(); return false; } - private static bool TryWrite(Writer writer, Address address, T value, out string? failure) + private static bool TryWrite(Writer writer, Address address, T value, + out MemoryAccessFailure failure) { TValue writeValue = Unsafe.As(ref value); - if (writer(address, writeValue, out MemoryAccessFailure sdkFailure)) - { - failure = null; - return true; - } - - failure = sdkFailure.ToString(); - return false; + return writer(address, writeValue, out failure); } private delegate bool Reader(Address address, out T value, out MemoryAccessFailure failure); @@ -190,7 +226,10 @@ private static bool TryWrite(Writer writer, Address address, private delegate bool Writer(Address address, T value, out MemoryAccessFailure failure); } -/// Calls the SDK memory primitives after the owning context has admitted the operation. +/// +/// Calls the SDK memory primitives after the owning context has admitted the operation; its target facts are the +/// read-only observations of . +/// internal sealed class SdkMemoryCodecContextPort : IMemoryCodecContextPort { private SdkMemoryCodecContextPort() @@ -202,32 +241,29 @@ internal static SdkMemoryCodecContextPort Instance get; } = new(); - public bool IsTarget64Bit() + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) { - return ClientLuaGlobals.TargetIs64Bit(); + return SdkRuntimeObservationPort.Instance.ObserveCurrent(out observation); } - public bool TryReadBytes(Address address, Span destination, out string? failure) + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) { - if (TargetMemory.TryReadBytes(address, destination, out MemoryAccessFailure sdkFailure)) - { - failure = null; - return true; - } + return SdkRuntimeObservationPort.Instance.ObserveTargetArchitecture(out observation); + } - failure = sdkFailure.ToString(); - return false; + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize) + { + return SdkRuntimeObservationPort.Instance.TryGetConfiguredPointerSize(out rawBytes, out pointerSize); } - public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure) + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) { - if (TargetMemory.TryWriteBytes(address, source, out MemoryAccessFailure sdkFailure)) - { - failure = null; - return true; - } + return TargetMemory.TryReadBytes(address, destination, out written, out failure); + } - failure = sdkFailure.ToString(); - return false; + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) + { + return TargetMemory.TryWriteBytes(address, source, out failure); } } diff --git a/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs index 57dec30..838c2c8 100644 --- a/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs +++ b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs @@ -1,14 +1,16 @@ -using CheatEngine.SDK.Engine.Runtime; - namespace CheatEngine.Client.Core.Domains; -/// Internal adapter boundary for CE process globals and local managed process metadata. +/// Internal adapter boundary for local managed process metadata. +/// +/// The target facts come from and the attach call from +/// (CheatEngine.SDK operations). Local metadata comes from the base class +/// library: it describes local processes only and never a CEServer or file-as-process target. +/// internal interface IProcessHost { - public long GetOpenedProcessId(); - public void OpenProcess(long processId); public bool TryGetLocalProcess(int processId, out LocalProcessInfo process); + public IReadOnlyList GetLocalProcesses(); + public IReadOnlyList FindProcessesByExactName(string processName); - public CheatEngineArchitecture GetTargetArchitecture(); } diff --git a/libs/CheatEngine.Client.Core/Domains/IProcessSelectionPort.cs b/libs/CheatEngine.Client.Core/Domains/IProcessSelectionPort.cs new file mode 100644 index 0000000..41e673a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IProcessSelectionPort.cs @@ -0,0 +1,24 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal port for the one Cheat Engine call of the Processes domain that changes the selected target. +/// +/// +/// is CheatEngine.SDK 2.0.0 RuntimeProcessOperations.SelectAndObserve: +/// openProcess, then the selected PID and bitness in the same Lua admission. A normal return of +/// openProcess is not success by itself: success requires the next selected-PID read to equal the +/// requested process. Selecting a process also resets Cheat Engine's configured pointer size. +/// +/// +/// It is kept apart from , which only observes, and only +/// ProcessClient.TryAttach calls it (architecture ratchet). +/// +/// +internal interface IProcessSelectionPort +{ + /// Selects an explicit process and verifies Cheat Engine's resulting selection. + public ProcessOperationStatus SelectAndObserve(TargetProcessId processId, + out CurrentProcessObservation observation); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IRuntimeObservationPort.cs b/libs/CheatEngine.Client.Core/Domains/IRuntimeObservationPort.cs new file mode 100644 index 0000000..2f2867b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IRuntimeObservationPort.cs @@ -0,0 +1,59 @@ +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Internal, read-only port for the Cheat Engine host and target facts of the runtime snapshot, so the Runtime and +/// Processes domains are testable without the CheatEngine.SDK statics. +/// +/// +/// +/// Every member is one read-only CheatEngine.SDK 2.0.0 operation: RuntimeObservations.TryObserveRuntimeInfo, +/// the RuntimeHostOperations and RuntimeProcessOperations observations, and +/// TargetSelection.ObserveCurrent and ValidateCurrent. None of them selects a +/// target, loads a driver or a table, executes code remotely or changes a host setting (audit ADR-09a, Q45); the +/// architecture ratchet (RuntimeProbeCallsOnlyReadOnlySdkOperations) keeps the production port to that +/// list. +/// +/// +/// The SDK reports every outcome as a status and never through Lua error text. An operation that cannot acquire +/// its Lua admission (for example a plugin that is not enabled) throws , +/// which the calling domain classifies through . +/// +/// +internal interface IRuntimeObservationPort : ITargetObservationPort +{ + /// Observes the host and, when one is selected, the target in one Lua admission. + public ProcessOperationStatus TryObserveRuntimeInfo(out RuntimeInfo? info); + + /// Observes the four host facts in one Lua admission (RuntimeHostOperations.ObserveHost). + public LuaOperationStatus ObserveHost(out CheatEngineHostObservation host); + + /// Reads the complete Cheat Engine file version (getCheatEngineFileVersion). + public LuaOperationStatus TryGetCheatEngineFileVersion(out CheatEngineVersion version); + + /// Reads the Cheat Engine host architecture (getSystemArchitecture). + public LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchitecture architecture); + + /// Reads whether the Cheat Engine process is 64-bit (cheatEngineIs64Bit). + public LuaOperationStatus TryIsCheatEngine64Bit(out bool is64Bit); + + /// Reads the operating system Cheat Engine runs on (getOperatingSystem). + public LuaOperationStatus TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem); + + /// + /// Observes what identifies the selected target (TargetSelection.ObserveCurrent): its PID, its backend and, + /// for a local process, its incarnation (PID and observed creation time). + /// + public TargetSelectionFacts ObserveSelection(); + + /// + /// Checks whether the current selection still denotes + /// (TargetSelection.ValidateCurrent): current, a different PID, the same PID reused by another process, or + /// no comparable incarnation. + /// + public TargetIdentityFacts ValidateSelection(TargetProcessIncarnation expected); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs b/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs deleted file mode 100644 index caab5fa..0000000 --- a/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CheatEngine.Client.Core.Domains; - -/// Internal port for CE runtime globals, so the domain is testable without mocking generated SDK statics. -internal interface IRuntimeProbe -{ - public double GetCheatEngineVersion(); - public int GetSystemArchitecture(); - public int GetTargetAbi(); - public long GetOpenedProcessId(); - public bool TargetIs64Bit(); -} diff --git a/libs/CheatEngine.Client.Core/Domains/ISymbolRegistrationHandle.cs b/libs/CheatEngine.Client.Core/Domains/ISymbolRegistrationHandle.cs new file mode 100644 index 0000000..8b65449 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ISymbolRegistrationHandle.cs @@ -0,0 +1,21 @@ +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Internal view of one CheatEngine.SDK SymbolRegistrationLease: the release authority of a symbol that the +/// SDK's ownership coordinator registered. +/// +/// +/// The SDK lease has no public constructor, so Core owns it only through this handle and the Client lease tests use a +/// fake. The handle is called on Cheat Engine's main thread only. +/// +internal interface ISymbolRegistrationHandle +{ + /// Attempts the coordinator-qualified unregistration (SymbolRegistrationLease.Release). + /// + /// The kind CheatEngine.SDK reports. Only and + /// leave the SDK lease retryable. + /// + public SymbolRegistrationReleaseKind Release(); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ITableFilePort.cs b/libs/CheatEngine.Client.Core/Domains/ITableFilePort.cs new file mode 100644 index 0000000..0602421 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ITableFilePort.cs @@ -0,0 +1,19 @@ +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal boundary for Cheat Engine's table file primitives, after the Client trust policy admitted the path. +/// +/// The file form of Cheat Engine's loadTable has no option to suppress the table's Lua script prompt, so a +/// table with scripts may prompt or execute Lua. A refused path is never retried through another overload and never +/// turned into a stream (audit A14-27, A14-41). Each member reports CheatEngine.SDK's binding outcome, which +/// classifies. +/// +internal interface ITableFilePort +{ + /// Loads a table file into the current Address List, merging or replacing it. + public LuaOperationStatus TryLoad(string path, bool merge); + + /// Saves the current table to a file. + public LuaOperationStatus TrySave(string path); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ITableHierarchyPort.cs b/libs/CheatEngine.Client.Core/Domains/ITableHierarchyPort.cs new file mode 100644 index 0000000..7843ca4 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ITableHierarchyPort.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Resolves the root of a hierarchy copy, so that 's walk of each record's children by +/// position is testable without Cheat Engine. Every member is read-only. +/// +internal interface ITableHierarchyPort +{ + /// Resolves the record that roots a hierarchy copy in the current Address List. + /// The identifier of the root record. + /// The root record when the result is . + /// + /// , or + /// . + /// + public RecordLookupStatus TryGetRoot(MemoryRecordId id, out ITableHierarchyRecord? root); +} + +/// One record reached by a hierarchy copy: its snapshot and its immediate children, by position. +internal interface ITableHierarchyRecord +{ + /// Copies the record, with its reported child count in its state. + /// The copied record on success. + /// when every required field was read. + public bool TrySnapshot(out MemoryRecordSnapshot snapshot); + + /// Gets the immediate child at . + /// The zero-based position of the child. + /// The child on success. + /// + /// when Cheat Engine returned a child at that position; a position past the last child and + /// a failed read both return . + /// + public bool TryGetChild(int index, [NotNullWhen(true)] out ITableHierarchyRecord? child); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ITableRecordLookupPort.cs b/libs/CheatEngine.Client.Core/Domains/ITableRecordLookupPort.cs index 1431814..05dd713 100644 --- a/libs/CheatEngine.Client.Core/Domains/ITableRecordLookupPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/ITableRecordLookupPort.cs @@ -5,7 +5,7 @@ namespace CheatEngine.Client.Core.Domains; /// /// Separates static SDK Address List access from lookup classification so the Client's public failure contract -/// remains deterministic and directly testable. +/// remains deterministic and directly testable. Every member is read-only. /// internal interface ITableRecordLookupPort { @@ -14,4 +14,10 @@ internal interface ITableRecordLookupPort public RecordLookupStatus TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record); public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record); + + /// + /// Copies every top-level record when the table holds at most records; a larger + /// table is and nothing is copied. + /// + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table); } diff --git a/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs index 2e1c7f1..852cd65 100644 --- a/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs @@ -5,12 +5,33 @@ namespace CheatEngine.Client.Core.Domains; /// /// Separates the static SDK address-list access from mutation classification so the Client's public failure contract -/// remains deterministic and directly testable. +/// remains deterministic and directly testable. Every member is a host-visible mutation and reports its outcome in +/// CheatEngine.SDK's mutation vocabulary, which classifies. /// internal interface ITableRecordMutationPort { - public TableRecordMutationStatus TryDelete(MemoryRecordId id); + /// Creates, initializes and optionally re-parents one record; a failed creation is rolled back once. + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record); - public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + /// Deletes one record through AddressListMutations.Delete. + public TableRecordMutationOutcome TryDelete(MemoryRecordId id); + + /// + /// Assigns a parent through AddressListMutations.SetParent, which validates the parent chain within an + /// explicit traversal limit, then copies the moved record. + /// + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record); + + /// + /// Changes the Active state of one record through AddressListMutations.SetActive, which calls the + /// setter at most once and never retries, then copies the record. + /// + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested); + + /// + /// Selects one record in Cheat Engine's Address List. This changes the GUI selection that the user and other + /// plugins see: a host-visible mutation, not a cache operation (audit A14-08). + /// + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record); } diff --git a/libs/CheatEngine.Client.Core/Domains/ITargetObservationPort.cs b/libs/CheatEngine.Client.Core/Domains/ITargetObservationPort.cs new file mode 100644 index 0000000..56aaa6f --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ITargetObservationPort.cs @@ -0,0 +1,40 @@ +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Internal, read-only port for the facts Cheat Engine reports about its selected target: the process identifier, +/// the bitness, the backend, the ISA families, the ABI and the configured pointer size. +/// +/// +/// +/// Each member is one CheatEngine.SDK 2.0.0 RuntimeProcessOperations observation, run in its own Lua +/// admission. The SDK reads the selected process identifier before and after the facts and reads no fact when +/// no target, or the file-as-process sentinel, is selected; the Client does not bracket, derive or re-read +/// anything itself. A member never selects, opens, pauses or configures a target and never changes the configured +/// pointer size (audit ADR-09a, Q45). +/// +/// +/// The Runtime, Processes and Memory domains share this port through , +/// so one target observation policy serves every domain. Test doubles implement it; production uses +/// . +/// +/// +internal interface ITargetObservationPort +{ + /// Observes the selected process identifier and its bitness (RuntimeProcessOperations.ObserveCurrent). + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation); + + /// + /// Observes every fact about the selected target between two selected-PID reads + /// (RuntimeProcessOperations.ObserveTargetArchitecture). + /// + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + /// + /// Reads the configured pointer size between two selected-PID reads + /// (RuntimeProcessOperations.TryGetConfiguredPointerSize); the raw value is kept for any integer. + /// + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ITargetSelectionBinder.cs b/libs/CheatEngine.Client.Core/Domains/ITargetSelectionBinder.cs new file mode 100644 index 0000000..7a31ad2 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ITargetSelectionBinder.cs @@ -0,0 +1,38 @@ +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Internal boundary that keys a new target-bound owner to the selection epoch of the process incarnation that +/// CheatEngine.SDK bound it to. implements it, since it owns the observed selection. +/// +/// +/// The target-selection epoch advances only when the Client observes Cheat Engine's selection. A process selected in +/// Cheat Engine's own window since the last observation is not reflected in the epoch yet: an owner registered under +/// the current epoch would then be keyed to the earlier process, and the next observation would release it while its +/// own target is still selected. The incarnation of the new owner is therefore recorded first, as an observation of +/// the selection, and the owner is registered under the epoch that this observation leaves. +/// +internal interface ITargetSelectionBinder +{ + /// + /// Records the process incarnation of a new owner as an observation of Cheat Engine's selection. The epoch advances, + /// and the earlier selection's leases are released, when the incarnation names another process, another + /// incarnation of the same identifier, or another backend than the Client last observed. + /// + /// The incarnation that CheatEngine.SDK bound the new owner to. + /// The public Client operation that created the owner. + /// The epoch to register the owner under, and the reason of the advance to report, if any. + /// Runs on Cheat Engine's main thread, inside the dispatched callback that created the owner. + public TargetSelectionBinding BindOwner(TargetProcessIncarnation incarnation, string operation); + + /// Reports the selection advance of a binding (EventId 1100), after the dispatched callback returned. + /// The binding. + /// The public Client operation that created the owner. + public void ReportBinding(TargetSelectionBinding binding, string operation); +} + +/// The selection epoch a new owner belongs to, and the reason of the advance its binding made. +/// The target-selection epoch to register the owner under. +/// The closed reason name of the advance, or without one. +internal readonly record struct TargetSelectionBinding(long SelectionEpoch, string? AdvanceReason); diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs index d0b24ec..81ae7c3 100644 --- a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs @@ -7,6 +7,7 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Domains; @@ -15,32 +16,48 @@ internal sealed class InspectionClient( CoreLifetime lifetime, IInspectionPort? inspection = null) : IInspectionClient { + private const string RegisterOperation = "Inspection.RegisterSymbol"; + + private const string ResolveNameOperation = "Inspection.ResolveName"; + private readonly SdkMainThreadDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); private readonly IInspectionPort _inspection = inspection ?? new SdkInspectionPort(); private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); - private readonly HashSet _registeredSymbolNames = new(StringComparer.Ordinal); + + // Cheat Engine's case rules for user symbols are not established, so the activation-local reservation is + // conservative: names that differ only by case are treated as the same name. + private readonly HashSet _registeredSymbolNames = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _registeredSymbolNamesLock = new(); - public bool TryGetModules(InspectionCollectionRequest request, out ImmutableArray modules, - out CheatEngineFailure failure, TargetProcessId? processId = null, + public bool TryGetModules(InspectionCollectionRequest request, TargetProcessId? processId, + out ImmutableArray modules, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + // Arguments first, then the activation: an ended or stopping activation throws before any failure. + ValidateCollectionRequest(request); + if (processId is { Value: <= 0 } invalidProcessId) + { + throw new ArgumentOutOfRangeException(nameof(processId), invalidProcessId.Value, + "A target process identifier must be positive."); + } + + _lifetime.ThrowIfDispatchRefused("Inspection.GetModules"); ImmutableArray result = ImmutableArray.Empty; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => - { - ModuleInfo[] buffer = new ModuleInfo[request.MaximumItems]; - status = processId.HasValue - ? _inspection.EnumerateModules(processId.Value, buffer, out int written) - : _inspection.EnumerateModules(buffer, out written); - if (status == InspectionStatus.Success) - { - result = ImmutableArray.Create(buffer, 0, written); - } - }, out failure, cancellationToken)) + if (!SdkBoundary.TryInvoke(_dispatcher, "Inspection.GetModules", () => + { + ModuleInfo[] buffer = new ModuleInfo[request.MaximumItems]; + status = processId.HasValue + ? _inspection.EnumerateModules(processId.Value, buffer, out int written) + : _inspection.EnumerateModules(buffer, out written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) { modules = []; return false; @@ -53,13 +70,13 @@ public bool TryGetModules(InspectionCollectionRequest request, out ImmutableArra public ImmutableArray GetModules(InspectionCollectionRequest request, TargetProcessId? processId = null, CancellationToken cancellationToken = default) { - if (TryGetModules(request, out ImmutableArray result, out CheatEngineFailure failure, processId, - cancellationToken)) + if (TryGetModules(request, processId, out ImmutableArray result, out CheatEngineFailure failure, + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return []; } @@ -67,17 +84,26 @@ public bool TryGetModuleSections(ModuleName moduleName, InspectionCollectionRequ out ImmutableArray sections, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + // Arguments first, then the activation: an ended or stopping activation throws before any failure. + if (string.IsNullOrWhiteSpace(moduleName.Value)) + { + throw new ArgumentException("A module name must not be empty; the default module name has none.", + nameof(moduleName)); + } + + ValidateCollectionRequest(request); + _lifetime.ThrowIfDispatchRefused("Inspection.GetModuleSections"); ImmutableArray result = ImmutableArray.Empty; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => - { - ModuleSectionInfo[] buffer = new ModuleSectionInfo[request.MaximumItems]; - status = _inspection.EnumerateSections(moduleName, buffer, out int written); - if (status == InspectionStatus.Success) - { - result = ImmutableArray.Create(buffer, 0, written); - } - }, out failure, cancellationToken)) + if (!SdkBoundary.TryInvoke(_dispatcher, "Inspection.GetModuleSections", () => + { + ModuleSectionInfo[] buffer = new ModuleSectionInfo[request.MaximumItems]; + status = _inspection.EnumerateSections(moduleName, buffer, out int written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) { sections = []; return false; @@ -91,12 +117,12 @@ public ImmutableArray GetModuleSections(ModuleName moduleName InspectionCollectionRequest request, CancellationToken cancellationToken = default) { if (TryGetModuleSections(moduleName, request, out ImmutableArray result, - out CheatEngineFailure failure, cancellationToken)) + out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return []; } @@ -104,17 +130,20 @@ public bool TryGetMemoryRegions(InspectionCollectionRequest request, out ImmutableArray regions, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + // Arguments first, then the activation: an ended or stopping activation throws before any failure. + ValidateCollectionRequest(request); + _lifetime.ThrowIfDispatchRefused("Inspection.GetMemoryRegions"); ImmutableArray result = []; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => - { - MemoryRegionInfo[] buffer = new MemoryRegionInfo[request.MaximumItems]; - status = _inspection.EnumerateMemoryRegions(buffer, out int written); - if (status == InspectionStatus.Success) - { - result = ImmutableArray.Create(buffer, 0, written); - } - }, out failure, cancellationToken)) + if (!SdkBoundary.TryInvoke(_dispatcher, "Inspection.GetMemoryRegions", () => + { + MemoryRegionInfo[] buffer = new MemoryRegionInfo[request.MaximumItems]; + status = _inspection.EnumerateMemoryRegions(buffer, out int written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) { regions = []; return false; @@ -128,22 +157,24 @@ public ImmutableArray GetMemoryRegions(InspectionCollectionReq CancellationToken cancellationToken = default) { if (TryGetMemoryRegions(request, out ImmutableArray result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return []; } public bool TryGetMemoryRegion(Address address, out MemoryRegionInfo region, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + _lifetime.ThrowIfDispatchRefused("Inspection.GetMemoryRegion"); MemoryRegionInfo captured = default; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => status = _inspection.GetMemoryRegion(address, out captured), - out failure, cancellationToken)) + if (!SdkBoundary.TryInvoke(_dispatcher, "Inspection.GetMemoryRegion", + () => status = _inspection.GetMemoryRegion(address, out captured), CheatEngineHostEffect.Unknown, + _lifetime, out failure, cancellationToken)) { region = default; return false; @@ -160,17 +191,20 @@ public MemoryRegionInfo GetMemoryRegion(Address address, CancellationToken cance return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } public bool TryGetSymbol(SymbolExpression expression, out SymbolInfo symbol, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateExpression(expression); + _lifetime.ThrowIfDispatchRefused("Inspection.GetSymbol"); SymbolInfo captured = default; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => status = _inspection.GetSymbol(expression, out captured), - out failure, cancellationToken)) + if (!SdkBoundary.TryInvoke(_dispatcher, "Inspection.GetSymbol", + () => status = _inspection.GetSymbol(expression, out captured), CheatEngineHostEffect.Unknown, + _lifetime, out failure, cancellationToken)) { symbol = default; return false; @@ -187,36 +221,32 @@ public SymbolInfo GetSymbol(SymbolExpression expression, CancellationToken cance return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } public bool TryResolveName(Address address, [NotNullWhen(true)] out string? name, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + _lifetime.ThrowIfDispatchRefused(ResolveNameOperation); string? captured = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - succeeded = _inspection.TryResolveName(ToNativeAddress(address), out captured), - out failure, - cancellationToken)) + LuaOperationStatus status = default; + if (!SdkBoundary.TryInvoke(_dispatcher, ResolveNameOperation, + () => status = _inspection.TryGetName(address, out captured), + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) { name = null; return false; } - if (succeeded && captured is not null) + if (status.IsSuccess && captured is not null) { name = captured; return true; } name = null; - failure = succeeded - ? new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Inspection.ResolveName", - "Cheat Engine returned an invalid symbol-name result.") - : new CheatEngineFailure(CheatEngineFailureKind.NotFound, "Inspection.ResolveName", - "Cheat Engine did not return a symbol name for the requested address."); + failure = InspectionMapping.NameLookupFailure(ResolveNameOperation, status.Kind); return false; } @@ -227,7 +257,7 @@ public string ResolveName(Address address, CancellationToken cancellationToken = return result; } - failure.Throw(); + failure.Throw(cancellationToken); return string.Empty; } @@ -236,73 +266,88 @@ public bool TryRegisterSymbol(SymbolRegistration registration, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { lease = null; - _lifetime.ThrowIfInactive("Inspection.RegisterSymbol"); + if (string.IsNullOrWhiteSpace(registration.Name)) + { + // Only the default registration has no name: its constructor throws for an empty one. + throw new ArgumentException( + "A symbol registration must name the symbol; the default registration has no name.", + nameof(registration)); + } + + _lifetime.ThrowIfInactive(RegisterOperation); if (!TryReserveSymbolName(registration.Name, out failure)) { return false; } - if (!_dispatcher.TryInvoke(() => _inspection.RegisterSymbol(registration.Name, - ToNativeAddress(registration.Address), registration.DoNotSave), out failure, cancellationToken)) + // The collision pre-check, the registration and the activation ownership run in one dispatched callback (audit + // A14-25, A14-39): CheatEngine.SDK keeps Cheat Engine's behavior for an existing name, so registerSymbol would + // shadow or replace a definition that already resolves, and only a name that resolves to nothing is registered. + // An SDK fault inside the registration leaves it unknown: the Client neither claims it nor retries an + // unregistration by name. A registration CheatEngine.SDK could not hand over (SymbolRegistrationHandoffException) + // was compensated once by the SDK, and SdkBoundary reports it CleanupUnconfirmed. + RegistrationStep step = default; + bool dispatched; + try + { + dispatched = SdkBoundary.TryInvoke(_dispatcher, RegisterOperation, + () => step = RegisterOnMainThread(registration), CheatEngineHostEffect.Unknown, _lifetime, out failure, + cancellationToken); + } + catch (Exception) + { + // A lifecycle exception of the dispatch or of the callback's admission registered nothing. + ReleaseSymbolName(registration.Name); + throw; + } + + if (!dispatched) { ReleaseSymbolName(registration.Name); return false; } - SymbolRegistrationLease created = new( - registration, - _dispatcher, - lease => _lifetime.Untrack(lease), - _inspection.UnregisterSymbol, - ReleaseSymbolName); - try + if (step.Lease is { } created) { - _lifetime.Track(created); lease = created; failure = default; return true; } - catch (Exception exception) - { - try - { - created.Dispose(); - } - catch - { - // The original lifecycle failure is the meaningful result. Release the name below only if disposal did not. - } - - if (!created.IsReleased) - { - ReleaseSymbolName(registration.Name); - } - failure = CoreFailureFactory.FromException("Inspection.RegisterSymbol", exception); - return false; - } + ReleaseSymbolName(registration.Name); + failure = CreateRegistrationFailure(step); + return false; } public ISymbolRegistrationLease RegisterSymbol(SymbolRegistration registration, CancellationToken cancellationToken = default) { if (TryRegisterSymbol(registration, out ISymbolRegistrationLease? result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); throw new InvalidOperationException("Unreachable failure flow."); } - public bool TryResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + public bool TryResolveAddress(SymbolExpression expression, AddressResolutionMode mode, out Address address, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateExpression(expression); + if (!Enum.IsDefined(mode)) + { + throw new ArgumentOutOfRangeException(nameof(mode), mode, + "The address resolution mode must be a defined value."); + } + + _lifetime.ThrowIfDispatchRefused("Inspection.ResolveAddress"); Address captured = Address.Zero; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => status = _inspection.ResolveAddress(expression, options, out captured), - out failure, cancellationToken)) + if (!SdkBoundary.TryInvoke(_dispatcher, "Inspection.ResolveAddress", + () => status = _inspection.ResolveAddress(expression, mode, out captured), + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) { address = default; return false; @@ -312,20 +357,56 @@ public bool TryResolveAddress(SymbolExpression expression, AddressResolutionOpti return TryMap(status, "Inspection.ResolveAddress", out failure); } - public Address ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + public Address ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, CancellationToken cancellationToken = default) { - if (TryResolveAddress(expression, options, out Address result, out CheatEngineFailure failure, - cancellationToken)) + if (TryResolveAddress(expression, mode, out Address result, out CheatEngineFailure failure, + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } - private static bool TryMap(InspectionStatus status, string operation, out CheatEngineFailure failure) + /// + /// Throws for the default collection request, which allows no item, as its constructor throws for a limit below + /// one: a copy into no room would otherwise reach Cheat Engine only to exceed it. + /// + /// The request allows no item. + private static void ValidateCollectionRequest(InspectionCollectionRequest request) + { + if (request.MaximumItems <= 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.MaximumItems, + "An inspection collection request must allow at least one item; the default request allows none."); + } + } + + /// + /// Throws for the default symbol expression, as its constructor throws for an empty one, before CheatEngine.SDK + /// would refuse it on Cheat Engine's main thread. + /// + /// The expression is empty. + private static void ValidateExpression(SymbolExpression expression) + { + if (string.IsNullOrWhiteSpace(expression.Value)) + { + throw new ArgumentException("A symbol expression must not be empty; the default expression has none.", + nameof(expression)); + } + } + + /// Maps an SDK inspection status by value; internal so the Q48 contract tests can prove it is total. + /// + /// An unavailable inspection global is with + /// : Cheat Engine was not called, as the memory and Address List + /// lookups report it. A status this Client does not recognize fails closed as + /// , like ; every + /// other failure keeps an unknown host effect. + /// + internal static bool TryMap(InspectionStatus status, string operation, out CheatEngineFailure failure) { if (status == InspectionStatus.Success) { @@ -340,12 +421,101 @@ private static bool TryMap(InspectionStatus status, string operation, out CheatE InspectionStatus.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, InspectionStatus.LuaFailure => CheatEngineFailureKind.LuaError, InspectionStatus.InvalidResult => CheatEngineFailureKind.InvalidHostResult, - _ => CheatEngineFailureKind.Unknown + _ => CheatEngineFailureKind.IndeterminateHostResult }; - failure = new CheatEngineFailure(kind, operation, $"Cheat Engine inspection returned '{status}'."); + CheatEngineHostEffect effect = status == InspectionStatus.GlobalUnavailable + ? CheatEngineHostEffect.NotStarted + : CheatEngineHostEffect.Unknown; + failure = new CheatEngineFailure(kind, operation, $"Cheat Engine inspection returned '{status}'.", null, + effect); return false; } + /// The refusal of a registration whose name already resolves or whose collision check failed. + private static CheatEngineFailure CreateCollisionFailure(InspectionStatus preflight) + { + if (preflight == InspectionStatus.Success) + { + return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, RegisterOperation, + "The symbol name already resolves in Cheat Engine (a registered symbol, a module or an expression that " + + "parses as an address); registering it would shadow or replace that definition.", null, + CheatEngineHostEffect.NotStarted); + } + + TryMap(preflight, RegisterOperation, out CheatEngineFailure mapped); + return new CheatEngineFailure(mapped.Kind, mapped.Operation, + $"The symbol collision check returned '{preflight}', so the ownership of the name cannot be established; " + + "nothing was registered.", null, CheatEngineHostEffect.NotStarted); + } + + /// + /// Runs on Cheat Engine's main thread: the collision pre-check, the registration through the CheatEngine.SDK + /// ownership coordinator, and the registration of the lease with the activation. + /// + /// + /// The lease joins the activation before the callback returns, so a registration never outlives an activation + /// that starts stopping afterwards. When the lease cannot join it (the activation stopped between the dispatch + /// admission and that step, or its resources were already drained, which closes the registry with an + /// ), the registration has no owner: it is released once, here, and the + /// failure reports what the release established. + /// + private RegistrationStep RegisterOnMainThread(SymbolRegistration registration) + { + // No lease can be registered once the activation stops or ends: refuse before Cheat Engine registers a name + // that no lease could own, as every other lease-creating operation does in its callback. + _lifetime.ThrowIfInactive(RegisterOperation); + InspectionStatus preflight = _inspection.ResolveAddress(new SymbolExpression(registration.Name), + AddressResolutionMode.Default, out _); + if (preflight != InspectionStatus.NotFound) + { + return new RegistrationStep(preflight, default, null, null, default); + } + + SymbolRegistrationAttempt attempt = _inspection.TryRegisterOwned(new SymbolName(registration.Name), + registration.Address, new SymbolRegistrationOptions(registration.DoNotSave)); + if (attempt.Handle is not { } handle) + { + return new RegistrationStep(preflight, attempt.Status, null, null, default); + } + + SymbolRegistrationLease lease = new(registration, handle, _dispatcher, ReleaseSymbolName, + _lifetime.Diagnostics); + try + { + lease.Register(_lifetime); + return new RegistrationStep(preflight, attempt.Status, lease, null, default); + } + catch (Exception exception) + { + // Whatever kept the lease out of the activation, nothing owns the registration: release it once. + return new RegistrationStep(preflight, attempt.Status, null, exception, + SdkReleaseOutcomes.FromSymbolRegistration(handle.Release())); + } + } + + /// Creates the failure of a registration that produced no lease; emitted after the dispatched work. + private CheatEngineFailure CreateRegistrationFailure(RegistrationStep step) + { + if (step.TrackingFault is { } fault) + { + // The one release made on the main thread removed the registration, or could not confirm it. + CheatEngineHostEffect effect = step.Compensation.IsComplete + ? CheatEngineHostEffect.Completed + : CheatEngineHostEffect.CleanupUnconfirmed; + return SdkBoundary.Translate(RegisterOperation, fault, effect, _lifetime); + } + + if (step.Preflight != InspectionStatus.NotFound) + { + // The reason only: the symbol name is never logged (A24-13). + _lifetime.Diagnostics.SymbolRegistrationRejected(RegisterOperation, + step.Preflight == InspectionStatus.Success ? "AlreadyResolves" : "LookupFailed"); + return CreateCollisionFailure(step.Preflight); + } + + return InspectionMapping.RegistrationFailure(RegisterOperation, step.Status.Kind); + } + private bool TryReserveSymbolName(string name, out CheatEngineFailure failure) { lock (_registeredSymbolNamesLock) @@ -357,8 +527,9 @@ private bool TryReserveSymbolName(string name, out CheatEngineFailure failure) } } - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Inspection.RegisterSymbol", - "This client activation already owns a symbol registration with the requested name."); + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, RegisterOperation, + "This client activation already owns a symbol registration with the requested name.", null, + CheatEngineHostEffect.NotStarted); return false; } @@ -370,8 +541,19 @@ private void ReleaseSymbolName(string name) } } - private static nuint ToNativeAddress(Address address) - { - return checked((nuint) address.Value); - } + /// What the dispatched registration established. + /// The collision pre-check status; only registers. + /// The registration status CheatEngine.SDK reported, when the registration was attempted. + /// The lease, registered with the activation, when the registration succeeded. + /// + /// The fault that prevented the activation from owning the lease: a lifecycle exception, or the + /// of a resource registry that was already drained. + /// + /// The outcome of the one release made after . + private readonly record struct RegistrationStep( + InspectionStatus Preflight, + LuaOperationStatus Status, + SymbolRegistrationLease? Lease, + Exception? TrackingFault, + LeaseReleaseOutcome Compensation); } diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionMapping.cs b/libs/CheatEngine.Client.Core/Domains/InspectionMapping.cs new file mode 100644 index 0000000..3fba835 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/InspectionMapping.cs @@ -0,0 +1,183 @@ +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Maps every that the CheatEngine.SDK 2.0.0 symbol registry reports +/// (SymbolRegistry.TryGetName and SymbolRegistry.TryRegisterOwned) to the Client vocabulary, value by +/// value, and the Client address resolution mode to the SDK options. +/// +/// +/// +/// A name lookup changes nothing, so its failure keeps an unknown host effect, except an unavailable global, +/// which Cheat Engine never called (NotStarted): +/// +/// +/// +/// SDK status +/// Name lookup failure kind +/// +/// Success without a nameInvalidHostResult +/// NilResultNotFound: Cheat Engine returned no name +/// GlobalUnavailableCapabilityUnavailable +/// LuaFailure, StackUnavailableLuaError +/// +/// InvalidResult, MissingResult, ResultCapacityExceeded +/// InvalidHostResult +/// +/// +/// Unknown or an undefined status +/// IndeterminateHostResult +/// +/// +/// +/// A registration that returned no lease maps to a failure kind and to what is known about the registration: +/// +/// +/// +/// SDK status +/// Registration failure kind and host effect +/// +/// +/// Success without a lease +/// +/// IndeterminateHostResult, CleanupUnconfirmed: Cheat Engine accepted a registration +/// that no lease owns. +/// +/// +/// +/// GlobalUnavailable +/// +/// CapabilityUnavailable, Unknown: CheatEngine.SDK also reports it when the Lua runtime +/// changed after Cheat Engine registered the name, which the Client cannot tell apart. +/// +/// +/// +/// StackUnavailable +/// LuaError, NotStarted: the call could not begin. +/// +/// +/// LuaFailure +/// LuaError, Started: registerSymbol ran and failed. +/// +/// +/// NilResult, InvalidResult, MissingResult, ResultCapacityExceeded +/// InvalidHostResult, Started: registerSymbol declares no result. +/// +/// +/// Unknown or an undefined status +/// IndeterminateHostResult, Unknown +/// +/// +/// +/// Messages name the category only, never a symbol name or an address. The mapping-totality tests fail when the +/// consumed SDK adds a value. +/// +/// +internal static class InspectionMapping +{ + /// Returns the CheatEngine.SDK options of an address resolution mode. + /// A defined resolution mode; refuses any other value. + /// + /// The options whose is only for + /// . + /// + internal static AddressResolutionOptions ToSdkResolutionOptions(AddressResolutionMode mode) + { + return new AddressResolutionOptions(mode == AddressResolutionMode.Shallow); + } + + /// Returns the failure kind of a name lookup that returned no name. + /// The status CheatEngine.SDK reported. + /// The failure kind; for an unrecognized value. + internal static CheatEngineFailureKind ToNameLookupFailureKind(LuaOperationStatusKind status) + { + return status switch + { + LuaOperationStatusKind.Success => CheatEngineFailureKind.InvalidHostResult, + LuaOperationStatusKind.NilResult => CheatEngineFailureKind.NotFound, + LuaOperationStatusKind.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + LuaOperationStatusKind.LuaFailure => CheatEngineFailureKind.LuaError, + LuaOperationStatusKind.StackUnavailable => CheatEngineFailureKind.LuaError, + LuaOperationStatusKind.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + LuaOperationStatusKind.MissingResult => CheatEngineFailureKind.InvalidHostResult, + LuaOperationStatusKind.ResultCapacityExceeded => CheatEngineFailureKind.InvalidHostResult, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Creates the failure of a name lookup that returned no name. + /// The public operation name. + /// The status CheatEngine.SDK reported. + /// + /// The failure: for an unavailable global, which Cheat Engine + /// never called, like every other inspection lookup; otherwise an unknown host effect, since a lookup changes + /// nothing. + /// + internal static CheatEngineFailure NameLookupFailure(string operation, LuaOperationStatusKind status) + { + CheatEngineFailureKind kind = ToNameLookupFailureKind(status); + string message = kind switch + { + CheatEngineFailureKind.NotFound => "Cheat Engine did not return a symbol name for the requested address.", + CheatEngineFailureKind.InvalidHostResult => "Cheat Engine returned an invalid symbol-name result.", + _ => $"The Cheat Engine symbol-name lookup returned '{status}'." + }; + CheatEngineHostEffect effect = status == LuaOperationStatusKind.GlobalUnavailable + ? CheatEngineHostEffect.NotStarted + : CheatEngineHostEffect.Unknown; + return new CheatEngineFailure(kind, operation, message, null, effect); + } + + /// Returns the failure kind of a registration that returned no lease. + /// The status CheatEngine.SDK reported. + /// The failure kind; for an unrecognized value. + internal static CheatEngineFailureKind ToRegistrationFailureKind(LuaOperationStatusKind status) + { + return status switch + { + LuaOperationStatusKind.Success => CheatEngineFailureKind.IndeterminateHostResult, + LuaOperationStatusKind.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + LuaOperationStatusKind.LuaFailure => CheatEngineFailureKind.LuaError, + LuaOperationStatusKind.StackUnavailable => CheatEngineFailureKind.LuaError, + LuaOperationStatusKind.NilResult => CheatEngineFailureKind.InvalidHostResult, + LuaOperationStatusKind.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + LuaOperationStatusKind.MissingResult => CheatEngineFailureKind.InvalidHostResult, + LuaOperationStatusKind.ResultCapacityExceeded => CheatEngineFailureKind.InvalidHostResult, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Returns what a registration that returned no lease establishes about the Cheat Engine side effect. + /// The status CheatEngine.SDK reported. + /// The host effect; unless the status proves more. + internal static CheatEngineHostEffect ToRegistrationHostEffect(LuaOperationStatusKind status) + { + return status switch + { + LuaOperationStatusKind.Success => CheatEngineHostEffect.CleanupUnconfirmed, + LuaOperationStatusKind.GlobalUnavailable => CheatEngineHostEffect.Unknown, + LuaOperationStatusKind.LuaFailure => CheatEngineHostEffect.Started, + LuaOperationStatusKind.StackUnavailable => CheatEngineHostEffect.NotStarted, + LuaOperationStatusKind.NilResult => CheatEngineHostEffect.Started, + LuaOperationStatusKind.InvalidResult => CheatEngineHostEffect.Started, + LuaOperationStatusKind.MissingResult => CheatEngineHostEffect.Started, + LuaOperationStatusKind.ResultCapacityExceeded => CheatEngineHostEffect.Started, + _ => CheatEngineHostEffect.Unknown + }; + } + + /// Creates the failure of a registration that returned no lease. + /// The public operation name. + /// The status CheatEngine.SDK reported. + /// The failure. + internal static CheatEngineFailure RegistrationFailure(string operation, LuaOperationStatusKind status) + { + return new CheatEngineFailure(ToRegistrationFailureKind(status), operation, + $"The symbol registration through the CheatEngine.SDK ownership coordinator returned '{status}' without " + + "a lease.", null, ToRegistrationHostEffect(status)); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessCatalog.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessCatalog.cs new file mode 100644 index 0000000..4b27a51 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessCatalog.cs @@ -0,0 +1,106 @@ +using System.Collections.Immutable; +using System.ComponentModel; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Enumerates the local operating-system process catalog for : +/// an offline diagnostic that never dispatches to Cheat Engine and needs no current activation. +/// +internal static class LocalProcessCatalog +{ + private const string Operation = "Processes.GetLocalProcesses"; + + /// Copies the local processes that match , ordered by identifier and bounded. + /// The request's bound is not positive. + /// The request's name filter is empty. + internal static bool TryEnumerate( + IProcessHost host, + LocalProcessEnumerationRequest request, + out LocalProcessEnumerationResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumResults); + if (request.NameContains is { Length: 0 }) + { + throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(request)); + } + + if (cancellationToken.IsCancellationRequested) + { + return Cancel(out result, out failure); + } + + try + { + IReadOnlyList processes = host.GetLocalProcesses(); + List matches = new(processes.Count); + foreach (LocalProcessInfo process in processes) + { + if (cancellationToken.IsCancellationRequested) + { + return Cancel(out result, out failure); + } + + if (Matches(request, process)) + { + matches.Add(process); + } + } + + matches.Sort(static (left, right) => left.Id.CompareTo(right.Id)); + int count = Math.Min(matches.Count, request.MaximumResults); + LocalProcessSnapshot[] snapshots = new LocalProcessSnapshot[count]; + for (int index = 0; index < count; index++) + { + if (cancellationToken.IsCancellationRequested) + { + return Cancel(out result, out failure); + } + + LocalProcessInfo process = matches[index]; + snapshots[index] = new LocalProcessSnapshot( + new LocalProcessId(process.Id), + process.Name, + process.ExecutablePath); + } + + result = new LocalProcessEnumerationResult(ImmutableArray.Create(snapshots), matches.Count > count); + failure = default; + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception + or PlatformNotSupportedException) + { + result = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + Operation, + "The local process list could not be materialized.", + exception, + CheatEngineHostEffect.NotStarted); + return false; + } + } + + private static bool Matches(LocalProcessEnumerationRequest request, LocalProcessInfo process) + { + return request.NameContains is null || + process.Name?.Contains(request.NameContains, StringComparison.OrdinalIgnoreCase) == true; + } + + /// No Cheat Engine work is ever dispatched here, so a cancellation always reports NotStarted. + private static bool Cancel(out LocalProcessEnumerationResult result, out CheatEngineFailure failure) + { + result = default; + failure = CancellationMapping.BeforeNativeCall(Operation, + "The operation was cancelled before local-process materialization completed."); + return false; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs deleted file mode 100644 index 846cff5..0000000 --- a/libs/CheatEngine.Client.Core/Domains/LocalProcessDiagnostics.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Collections.Immutable; -using System.ComponentModel; - -using CheatEngine.Client.Processes; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains; - -/// Provides the explicitly local, Cheat-Engine-independent process diagnostic contract. -internal sealed class LocalProcessDiagnostics(IProcessHost host) : ILocalProcessDiagnostics -{ - private readonly IProcessHost _host = host ?? throw new ArgumentNullException(nameof(host)); - - public bool TryGetProcesses( - ProcessEnumerationRequest request, - out ProcessEnumerationResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumItems); - if (request.NameContains is { Length: 0 }) - { - throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(request)); - } - - if (cancellationToken.IsCancellationRequested) - { - return Cancel(out result, out failure); - } - - try - { - IReadOnlyList processes = _host.GetLocalProcesses(); - List matches = new(processes.Count); - foreach (LocalProcessInfo process in processes) - { - if (cancellationToken.IsCancellationRequested) - { - return Cancel(out result, out failure); - } - - if (Matches(request, process)) - { - matches.Add(process); - } - } - - matches.Sort(static (left, right) => left.Id.CompareTo(right.Id)); - int count = Math.Min(matches.Count, request.MaximumItems); - ProcessInfoSnapshot[] snapshots = new ProcessInfoSnapshot[count]; - for (int index = 0; index < count; index++) - { - if (cancellationToken.IsCancellationRequested) - { - return Cancel(out result, out failure); - } - - LocalProcessInfo process = matches[index]; - snapshots[index] = new ProcessInfoSnapshot( - new LocalProcessId(process.Id), - process.Name, - process.ExecutablePath); - } - - result = new ProcessEnumerationResult(ImmutableArray.Create(snapshots), matches.Count > count); - failure = default; - return true; - } - catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception - or PlatformNotSupportedException) - { - result = default; - failure = new CheatEngineFailure( - CheatEngineFailureKind.OperationRejected, - "LocalProcesses.GetProcesses", - "The local process list could not be materialized.", - exception); - return false; - } - } - - public ProcessEnumerationResult GetProcesses( - ProcessEnumerationRequest request, - CancellationToken cancellationToken = default) - { - if (TryGetProcesses(request, out ProcessEnumerationResult result, out CheatEngineFailure failure, - cancellationToken)) - { - return result; - } - - failure.Throw(); - return default; - } - - private static bool Matches(ProcessEnumerationRequest request, LocalProcessInfo process) - { - return request.NameContains is null || - process.Name?.Contains(request.NameContains, StringComparison.OrdinalIgnoreCase) == true; - } - - private static bool Cancel(out ProcessEnumerationResult result, out CheatEngineFailure failure) - { - result = default; - failure = new CheatEngineFailure( - CheatEngineFailureKind.Cancelled, - "LocalProcesses.GetProcesses", - "The operation was cancelled before local-process materialization completed."); - return false; - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs index f8a5959..b9b504e 100644 --- a/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs @@ -1,23 +1,15 @@ using System.ComponentModel; using System.Diagnostics; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.SDK.Engine.Runtime; - namespace CheatEngine.Client.Core.Domains; +/// Production process host: local metadata through the base class library. +/// +/// Name and executable path come from and describe a local process only: they are not evidence +/// of a CEServer target or of a file opened as a process, whose identifiers do not name a local process. +/// internal sealed class LocalProcessHost : IProcessHost { - public long GetOpenedProcessId() - { - return ClientLuaGlobals.GetOpenedProcessId(); - } - - public void OpenProcess(long processId) - { - ClientLuaGlobals.OpenProcess(processId); - } - public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) { try @@ -55,7 +47,7 @@ public IReadOnlyList FindProcessesByExactName(string processNa for (int index = 0; index < processes.Count; index++) { if (TryCapture(processes[index], out LocalProcessInfo process) && - string.Equals(process.Name, processName, StringComparison.OrdinalIgnoreCase)) + string.Equals(process.Name, processName, StringComparison.OrdinalIgnoreCase)) { matches.Add(process); } @@ -64,13 +56,6 @@ public IReadOnlyList FindProcessesByExactName(string processNa return matches; } - public CheatEngineArchitecture GetTargetArchitecture() - { - return ClientLuaGlobals.TargetIs64Bit() - ? CheatEngineArchitecture.X64 - : CheatEngineArchitecture.X86; - } - private static bool TryCapture(Process process, out LocalProcessInfo captured) { try @@ -93,7 +78,7 @@ private static bool TryCapture(Process process, out LocalProcessInfo captured) return process.MainModule?.FileName; } catch (Exception exception) when (exception is InvalidOperationException or Win32Exception - or NotSupportedException) + or NotSupportedException) { return null; } diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs index 76630b2..d1b4d7f 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -11,7 +11,10 @@ namespace CheatEngine.Client.Core.Domains; internal sealed class LuaClient : ILuaClient { + private const string RegisterOperation = "Lua.RegisterModule"; + private readonly Action? _admitStatefulOperation; + private readonly ICoreDiagnostics _diagnostics; private readonly ICheatEngineDispatcher _dispatcher; private readonly Func _epochProvider; @@ -44,7 +47,8 @@ private LuaClient(ICheatEngineDispatcher dispatcher, LuaClientInitialization ini initialization.TrackLease, initialization.UntrackLease, initialization.AdmitStatefulOperation, - initialization.IsStopping) + initialization.IsStopping, + initialization.Diagnostics) { } @@ -56,9 +60,11 @@ internal LuaClient( Action? trackLease = null, Action? untrackLease = null, Action? admitStatefulOperation = null, - Func? isStopping = null) + Func? isStopping = null, + ICoreDiagnostics? diagnostics = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _diagnostics = GuardedCoreDiagnostics.Wrap(diagnostics); _epochProvider = epochProvider ?? throw new ArgumentNullException(nameof(epochProvider)); _isContextCurrent = isContextCurrent ?? throw new ArgumentNullException(nameof(isContextCurrent)); _isStopping = isStopping ?? (static () => false); @@ -75,29 +81,28 @@ public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILua out CheatEngineFailure failure, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(luaModule); + LuaModuleDescriptor descriptor = luaModule.Descriptor; + // A constructed descriptor always has a name; the default value has the empty one. + if (descriptor.Name.Length == 0) + { + throw new ArgumentException("The Lua module must declare its identity and exports in a descriptor.", + nameof(luaModule)); + } + lease = null; - Admit("Lua.RegisterModule"); + Admit(RegisterOperation); if (cancellationToken.IsCancellationRequested) { - failure = CoreFailureFactory.Cancelled("Lua.RegisterModule"); + failure = CoreFailureFactory.Cancelled(RegisterOperation); return false; } - try + if (!TryReserveModule(luaModule, descriptor, out failure)) { - if (!TryReserveModule(luaModule, out failure)) - { - return false; - } - } - catch (Exception exception) - { - failure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); return false; } - LuaModuleLease created = new(luaModule, _epochProvider(), _dispatcher, _isContextCurrent, _untrackLease, - ReleaseModule); + LuaModuleLease created = new(luaModule, _dispatcher, _diagnostics, _untrackLease, ReleaseModule); try { _trackLease(created); @@ -105,29 +110,29 @@ public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILua catch (Exception trackingException) { failure = FailAfterAbandoningUnregisteredLease( - CoreFailureFactory.FromException("Lua.RegisterModule", trackingException), + SdkBoundary.Classify(RegisterOperation, trackingException, CheatEngineHostEffect.NotStarted), created); return false; } bool registered = false; - CheatEngineFailure moduleFailure = default; + Exception? registrationFault = null; if (!_dispatcher.TryInvoke( - () => - { - try - { - luaModule.Register(); - created.ConfirmRegistration(); - registered = true; - } - catch (Exception exception) - { - moduleFailure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); - } - }, - out failure, - cancellationToken)) + () => + { + try + { + luaModule.Register(); + created.ConfirmRegistration(); + registered = true; + } + catch (Exception exception) + { + registrationFault = exception; + } + }, + out failure, + cancellationToken)) { failure = FailAfterAbandoningUnregisteredLease(failure, created); return false; @@ -135,7 +140,7 @@ public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILua if (!registered) { - failure = FailAfterAbandoningUnregisteredLease(moduleFailure, created); + failure = FailAfterAbandoningUnregisteredLease(ClassifyRegistrationFault(registrationFault), created); return false; } @@ -144,11 +149,12 @@ public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILua // Registration and lifetime tracking are deliberately handed off in this order. If shutdown starts while // Register runs, the already-tracked lease is drained by the hosting cleanup scope rather than redispatched // from this worker after ordinary dispatch admission has closed. - Admit("Lua.RegisterModule"); + Admit(RegisterOperation); } catch (Exception exception) { - CheatEngineFailure admissionFailure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); + CheatEngineFailure admissionFailure = + SdkBoundary.Classify(RegisterOperation, exception, CheatEngineHostEffect.Unknown); if (_isStopping()) { // The Core lifetime owns the pre-tracked, registered lease. Leaving it there gives the main-thread @@ -173,47 +179,20 @@ public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken ca return lease; } - failure.Throw(); + failure.Throw(cancellationToken); throw new InvalidOperationException("Unreachable failure flow."); } - public bool TryExecute(ILuaOperation operation, [MaybeNullWhen(false)] out TResult result, + public bool TryExecute(in TOperation operation, [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation { - ArgumentNullException.ThrowIfNull(operation); - Admit("Lua.Execute"); - if (cancellationToken.IsCancellationRequested) - { - result = default; - failure = CoreFailureFactory.Cancelled("Lua.Execute"); - return false; - } - - long epoch = _epochProvider(); - if (!TryDispatchOperation(operation, epoch, out LuaOperationResult operationResult, out failure, - cancellationToken)) - { - result = default; - return false; - } - - return TryMaterializeOperationResult(operationResult, out result, out failure); - } - - public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) - { - if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken)) + // A null test on an unconstrained type parameter never boxes a value operation. + if (operation is null) { - return result; + throw new ArgumentNullException(nameof(operation)); } - return ThrowFailure(failure); - } - - public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken) - where TOperation : struct, ILuaOperation - { Admit("Lua.Execute"); if (cancellationToken.IsCancellationRequested) { @@ -223,58 +202,60 @@ public bool TryExecute(TOperation operation, [MaybeNullWhen } long epoch = _epochProvider(); - if (!TryDispatchOperation(operation, epoch, out LuaOperationResult operationResult, out failure, - cancellationToken)) + long started = Stopwatch.GetTimestamp(); + bool completed; + if (TryDispatchOperation(operation, epoch, out LuaOperationResult operationResult, out failure, + cancellationToken)) + { + completed = TryMaterializeOperationResult(operationResult, out result, out failure); + } + else { result = default; - return false; + completed = false; } - return TryMaterializeOperationResult(operationResult, out result, out failure); + ReportOperation(completed, failure, started); + return completed; } - public TResult Execute(TOperation operation, CancellationToken cancellationToken) - where TOperation : struct, ILuaOperation + public TResult Execute(in TOperation operation, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation { - if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, - cancellationToken)) + if (TryExecute(in operation, out TResult? result, out CheatEngineFailure failure, + cancellationToken)) { return result; } - return ThrowFailure(failure); + return ThrowFailure(failure, cancellationToken); } - private static T ThrowFailure(CheatEngineFailure failure) + /// + /// Reports a typed operation with its failure kind and duration only (EventId 1600): never the operation type, a + /// Lua value or the failure message. + /// + private void ReportOperation(bool completed, CheatEngineFailure failure, long started) { - failure.Throw(); - throw new UnreachableException(); + _diagnostics.LuaOperationCompleted("Lua.Execute", completed ? "None" : failure.Kind.ToString(), + (long) Stopwatch.GetElapsedTime(started).TotalMilliseconds, 0); } - private LuaOperationResult ExecuteOperation(ILuaOperation operation, long epoch) + private static T ThrowFailure(CheatEngineFailure failure, CancellationToken cancellationToken) { - LuaOperationContext context = new(epoch, _isContextCurrent); - try - { - context.ThrowIfExpired(); - bool succeeded = operation.TryExecute(context, out TResult? result, out CheatEngineFailure failure); - return new LuaOperationResult(succeeded, result!, failure); - } - finally - { - context.Expire(); - } + failure.Throw(cancellationToken); + throw new UnreachableException(); } private LuaOperationResult ExecuteOperation(TOperation operation, long epoch) - where TOperation : struct, ILuaOperation + where TOperation : ILuaOperation { LuaOperationContext context = new(epoch, _isContextCurrent); try { context.ThrowIfExpired(); - // The constraint produces a constrained interface call for generated readonly record structs. This keeps the - // normal generated-operation path free of an ILuaOperation box. + // The type parameter produces a constrained interface call for generated readonly record structs. This keeps + // the generated-operation path free of an ILuaOperation box. bool succeeded = operation.TryExecute(context, out TResult? result, out CheatEngineFailure failure); return new LuaOperationResult(succeeded, result!, failure); } @@ -284,37 +265,13 @@ private LuaOperationResult ExecuteOperation(TOpera } } - private bool TryDispatchOperation( - ILuaOperation operation, - long epoch, - out LuaOperationResult result, - out CheatEngineFailure failure, - CancellationToken cancellationToken) - { - if (_dispatcher is IStatefulCheatEngineDispatcher statefulDispatcher) - { - return statefulDispatcher.TryInvoke( - new LuaOperationDispatchState(this, operation, epoch), - static dispatchState => dispatchState.Execute(), - out result, - out failure, - cancellationToken); - } - - return _dispatcher.TryInvoke( - () => ExecuteOperation(operation, epoch), - out result, - out failure, - cancellationToken); - } - private bool TryDispatchOperation( TOperation operation, long epoch, out LuaOperationResult result, out CheatEngineFailure failure, CancellationToken cancellationToken) - where TOperation : struct, ILuaOperation + where TOperation : ILuaOperation { if (_dispatcher is IStatefulCheatEngineDispatcher statefulDispatcher) { @@ -360,52 +317,48 @@ private static bool IsDefined(CheatEngineFailure failure) return !string.IsNullOrWhiteSpace(failure.Operation) && !string.IsNullOrWhiteSpace(failure.Message); } - private bool TryReserveModule(ILuaModule module, out CheatEngineFailure failure) + private bool TryReserveModule(ILuaModule module, LuaModuleDescriptor descriptor, out CheatEngineFailure failure) { + // Every module declares its identity and exports, so the complete name set is reserved before any Lua work. The + // module instance that is already registered is a resource state (InvalidState); a name another module of the + // activation reserved is a refused request (OperationRejected), as for a symbol name the activation owns. lock (_registeredModulesLock) { if (_registeredModules.ContainsKey(module)) { - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", - "This client activation already owns the supplied Lua module instance."); + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, RegisterOperation, + "This client activation already owns the supplied Lua module instance.", null, + CheatEngineHostEffect.NotStarted); return false; } - if (module is IDescribedLuaModule describedModule) + if (_reservedModuleNames.Contains(descriptor.Name)) { - LuaModuleDescriptor descriptor = describedModule.Descriptor; - if (_reservedModuleNames.Contains(descriptor.Name)) - { - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", - $"The Lua module identity '{descriptor.Name}' is already reserved by this client activation."); - return false; - } - - foreach (LuaExportDescriptor export in descriptor.Exports) - { - if (_reservedExportNames.Contains(export.Name)) - { - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", - $"The Lua export '{export.Name}' is already reserved by this client activation."); - return false; - } - } + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, RegisterOperation, + $"The Lua module identity '{descriptor.Name}' is already reserved by this client activation.", null, + CheatEngineHostEffect.NotStarted); + return false; + } - LuaModuleReservation reservation = LuaModuleReservation.Create(descriptor); - _registeredModules.Add(module, reservation); - _reservedModuleNames.Add(reservation.ModuleName!); - foreach (string exportName in reservation.ExportNames) + foreach (LuaExportDescriptor export in descriptor.Exports) + { + if (_reservedExportNames.Contains(export.Name)) { - _reservedExportNames.Add(exportName); + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, RegisterOperation, + $"The Lua export '{export.Name}' is already reserved by this client activation.", null, + CheatEngineHostEffect.NotStarted); + return false; } + } - failure = default; - return true; + LuaModuleReservation reservation = LuaModuleReservation.Create(descriptor); + _registeredModules.Add(module, reservation); + _reservedModuleNames.Add(reservation.ModuleName); + foreach (string exportName in reservation.ExportNames) + { + _reservedExportNames.Add(exportName); } - // Manual ILuaModule implementations remain a supported escape hatch. They participate in instance ownership - // only because the Client cannot truthfully infer their global Lua names without reflection or raw Lua access. - _registeredModules.Add(module, LuaModuleReservation.Manual); failure = default; return true; } @@ -420,17 +373,40 @@ private void ReleaseModule(ILuaModule module) return; } - if (reservation.ModuleName is not null) + _reservedModuleNames.Remove(reservation.ModuleName); + foreach (string exportName in reservation.ExportNames) { - _reservedModuleNames.Remove(reservation.ModuleName); - foreach (string exportName in reservation.ExportNames) - { - _reservedExportNames.Remove(exportName); - } + _reservedExportNames.Remove(exportName); } } } + /// + /// Reports the exception that a module's threw: the failure a Client exception + /// carries (a or a , + /// which a module, generated or not, obtains from ), otherwise the + /// classification with an unknown host effect. + /// + /// + /// Unlike a codec or a typed operation, whose exceptions are rethrown unchanged, a module's registration is + /// classified: a generated module surfaces the CheatEngine.SDK faults of its registration from + /// , and no SDK exception may cross (F15). A + /// Client exception always carries a classified failure: its constructors are internal, and + /// rejects the failure. + /// + private static CheatEngineFailure ClassifyRegistrationFault(Exception? fault) + { + return fault switch + { + // A module reports a classified failure through CheatEngineFailure.ToException: keep it, whatever its kind. + CheatEngineClientException reported => reported.Failure, + CheatEngineOperationCanceledException cancelled => cancelled.Failure, + null => new CheatEngineFailure(CheatEngineFailureKind.Unknown, RegisterOperation, + "The Lua module registration ended without completing or reporting a failure."), + _ => SdkBoundary.Classify(RegisterOperation, fault, CheatEngineHostEffect.Unknown) + }; + } + private static LuaClientInitialization CreateProductionInitialization(CoreLifetime lifetime) { ArgumentNullException.ThrowIfNull(lifetime); @@ -440,7 +416,8 @@ private static LuaClientInitialization CreateProductionInitialization(CoreLifeti () => lifetime.Stopping.IsCancellationRequested, lease => lifetime.Track(lease), lease => lifetime.Untrack(lease), - lifetime.ThrowIfInactive); + lifetime.ThrowIfInactive, + lifetime.Diagnostics); } private void Admit(string operation) @@ -465,21 +442,19 @@ private static CheatEngineFailure FailAfterAbandoningUnregisteredLease( private static CheatEngineFailure FailAfterRegisteredLease(CheatEngineFailure primaryFailure, LuaModuleLease lease) { - try - { - lease.Dispose(); - return primaryFailure; - } - catch (Exception cleanupException) - { - return WithSecondaryFailure(primaryFailure, cleanupException); - } + // Releasing never throws: an incomplete release stays tracked and is reported by the activation cleanup. + LeaseReleaseOutcome release = lease.Release(); + return release.IsComplete + ? primaryFailure + : new CheatEngineFailure(primaryFailure.Kind, primaryFailure.Operation, + $"{primaryFailure.Message} The registration was then released with the outcome {release}.", + primaryFailure.Exception, CheatEngineHostEffect.CleanupUnconfirmed); } private static CheatEngineFailure WithSecondaryFailure(CheatEngineFailure primaryFailure, Exception secondaryFailure) { - Exception primaryException = primaryFailure.Exception ?? new CheatEngineOperationException(primaryFailure); + Exception primaryException = primaryFailure.Exception ?? primaryFailure.ToException(); AggregateException combined = new( "Lua module registration failed and its handoff cleanup encountered an additional failure.", primaryException, @@ -502,29 +477,11 @@ private readonly record struct LuaClientInitialization( Func IsStopping, Action TrackLease, Action UntrackLease, - Action AdmitStatefulOperation); - - private readonly struct LuaOperationDispatchState - { - private readonly LuaClient _client; - private readonly long _epoch; - private readonly ILuaOperation _operation; - - internal LuaOperationDispatchState(LuaClient client, ILuaOperation operation, long epoch) - { - _client = client; - _operation = operation; - _epoch = epoch; - } - - internal LuaOperationResult Execute() - { - return _client.ExecuteOperation(_operation, _epoch); - } - } + Action AdmitStatefulOperation, + ICoreDiagnostics Diagnostics); private readonly struct LuaOperationDispatchState - where TOperation : struct, ILuaOperation + where TOperation : ILuaOperation { private readonly LuaClient _client; private readonly long _epoch; @@ -545,23 +502,18 @@ internal LuaOperationResult Execute() private sealed class LuaModuleReservation { - private LuaModuleReservation(string? moduleName, string[] exportNames) + private LuaModuleReservation(string moduleName, string[] exportNames) { ModuleName = moduleName; ExportNames = exportNames; } - internal static LuaModuleReservation Manual - { - get; - } = new(null, []); - internal string[] ExportNames { get; } - internal string? ModuleName + internal string ModuleName { get; } diff --git a/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs b/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs index 18145f7..45d587b 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs @@ -1,141 +1,174 @@ -using System.Runtime.ExceptionServices; - +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; namespace CheatEngine.Client.Core.Domains; /// Activation-owned release path for one explicitly registered application Lua module. -internal sealed class LuaModuleLease( - ILuaModule module, - long epoch, - ICheatEngineDispatcher dispatcher, - Func isActivationCurrent, - Action untrack, - Action releaseModule) : ILuaModuleLease +/// +/// +/// The release runs on Cheat Engine's main thread through : it calls the module's +/// and maps the reported with +/// . Dispose never throws; an exception from the module is an unconfirmed +/// cleanup that is never retried. +/// +/// +/// The lease is tracked through the activation delegates supplies. It leaves them only when +/// the outcome is complete, so a retryable outcome is retried by the activation cleanup and an outcome that requires +/// manual recovery is reported by it (audit Q43). The module's name reservation ends with the lease: once the +/// outcome is not retryable, the same module name and exports can be registered again. +/// +/// +internal sealed class LuaModuleLease : HostResourceLease, ILuaModuleLease { - private readonly ICheatEngineDispatcher _dispatcher = - dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - - private readonly object _disposeLock = new(); - - private readonly Func _isActivationCurrent = - isActivationCurrent ?? throw new ArgumentNullException(nameof(isActivationCurrent)); - - private readonly ILuaModule _module = module ?? throw new ArgumentNullException(nameof(module)); - - private readonly Action _releaseModule = - releaseModule ?? throw new ArgumentNullException(nameof(releaseModule)); - - private readonly Action _untrack = untrack ?? throw new ArgumentNullException(nameof(untrack)); - private int _registered; - private int _released; - - public long Epoch + private const string ReleaseOperation = "Lua.Release"; + + private readonly Lock _gate = new(); + private readonly ILuaModule _module; + private readonly Action _releaseReservation; + private readonly Action _untrack; + private bool _abandoned; + private LuaModuleReleaseOutcome? _moduleReleaseOutcome; + private bool _registered; + private bool _reservationReleased; + + internal LuaModuleLease(ILuaModule module, ICheatEngineDispatcher dispatcher, ICoreDiagnostics? diagnostics, + Action untrack, Action releaseReservation) + : base(ReleaseOperation, dispatcher, diagnostics) { - get; - } = epoch; - - public bool IsReleased => Volatile.Read(ref _released) != 0; + _module = module ?? throw new ArgumentNullException(nameof(module)); + _untrack = untrack ?? throw new ArgumentNullException(nameof(untrack)); + _releaseReservation = releaseReservation ?? throw new ArgumentNullException(nameof(releaseReservation)); + } - public void Dispose() + public LuaModuleReleaseOutcome? LastModuleReleaseOutcome { - lock (_disposeLock) + get { - if (Volatile.Read(ref _released) != 0) + lock (_gate) { - return; + return _moduleReleaseOutcome; } - - if (Volatile.Read(ref _registered) == 0) - { - CompleteRelease(); - return; - } - - // A detached activation has no legal SDK dispatch path left. There is nothing more that this lease can - // safely do, so release managed ownership without attempting to call Cheat Engine. - if (!_isActivationCurrent()) - { - CompleteRelease(); - return; - } - - _dispatcher.Invoke(_module.Unregister); - - // Do not make any ownership transition until the application module confirmed unregistration by returning. - // The write occurs while holding the lock, so another disposer either observes the completed release or - // waits and retries after the original dispatch fault. - CompleteRelease(); } } /// Marks the module as registered while the registration dispatcher callback still owns the main thread. + /// The lease was abandoned before registration completed. internal void ConfirmRegistration() { - lock (_disposeLock) + lock (_gate) { - if (Volatile.Read(ref _released) != 0) + if (_abandoned) { - throw new InvalidOperationException("The Lua module lease was released before registration completed."); + throw new InvalidOperationException("The Lua module lease was abandoned before registration completed."); } - Volatile.Write(ref _registered, 1); + _registered = true; } } - /// Releases a tracked handoff that never completed . + /// + /// Ends a tracked handoff whose never completed: nothing is released in Cheat + /// Engine, the lease leaves the activation and the name reservation ends. + /// + /// The module was registered; only a release can end the lease. internal void AbandonRegistration() { - lock (_disposeLock) + lock (_gate) { - if (Volatile.Read(ref _released) != 0) + if (_registered) { - return; + throw new InvalidOperationException( + "A registered Lua module lease cannot be abandoned without unregistration."); } - if (Volatile.Read(ref _registered) != 0) + if (_abandoned) { - throw new InvalidOperationException( - "A registered Lua module lease cannot be abandoned without unregistration."); + return; } - CompleteRelease(); + _abandoned = true; } - } - private void CompleteRelease() - { - Volatile.Write(ref _released, 1); - List? failures = null; try { _untrack(this); } - catch (Exception exception) + finally { - (failures ??= []).Add(exception); + ReleaseReservation(); } + } + protected override LeaseReleaseOutcome ReleaseOnMainThread() + { + lock (_gate) + { + if (!_registered) + { + // Nothing was registered, so nothing can remain in Cheat Engine. + ReleaseReservation(); + return new LeaseReleaseOutcome(LeaseReleaseKind.AlreadyReleased, CheatEngineHostEffect.NotStarted); + } + } + + LuaModuleReleaseOutcome moduleOutcome; try { - _releaseModule(_module); + moduleOutcome = _module.Unregister() ?? + throw new InvalidOperationException("The Lua module reported no release outcome."); + } + catch (Exception) + { + // The base records an unconfirmed cleanup that is never retried: the lease ends here. + ReleaseReservation(); + throw; + } + + lock (_gate) + { + _moduleReleaseOutcome = moduleOutcome; + } + + LeaseReleaseOutcome outcome = LuaModuleReleaseMapping.ToLeaseOutcome(moduleOutcome.Kind); + if (!outcome.IsRetryable) + { + ReleaseReservation(); } - catch (Exception exception) + + if (outcome.IsComplete) { - (failures ??= []).Add(exception); + Untrack(); } - if (failures is null) + return outcome; + } + + private void Untrack() + { + try + { + _untrack(this); + } + catch (Exception) { - return; + // The release itself completed: an activation that still tracks the lease finds it released at its drain. } + } - if (failures.Count == 1) + private void ReleaseReservation() + { + lock (_gate) { - ExceptionDispatchInfo.Capture(failures[0]).Throw(); + if (_reservationReleased) + { + return; + } + + _reservationReleased = true; } - throw new AggregateException("Lua module lease release encountered one or more cleanup failures.", failures); + _releaseReservation(_module); } } diff --git a/libs/CheatEngine.Client.Core/Domains/LuaModuleReleaseMapping.cs b/libs/CheatEngine.Client.Core/Domains/LuaModuleReleaseMapping.cs new file mode 100644 index 0000000..a28c1d1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaModuleReleaseMapping.cs @@ -0,0 +1,42 @@ +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Maps the release a Lua module reported () to the outcome of its Client lease. +/// +/// +/// The kind is kept; the host effect says how far the release got, with the rule of SdkReleaseOutcomes: +/// for a confirmed release, +/// for a release call that began without a confirmed result, when no +/// release write was made, and when nothing is known. The mapping is total +/// over and fails closed. +/// +internal static class LuaModuleReleaseMapping +{ + /// Maps a module release kind to the lease outcome. + /// The kind the module reported. + /// The lease outcome; with an unknown effect for an unknown kind. + internal static LeaseReleaseOutcome ToLeaseOutcome(LeaseReleaseKind kind) + { + return kind switch + { + LeaseReleaseKind.Unknown => Outcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown), + LeaseReleaseKind.Released => Outcome(kind, CheatEngineHostEffect.Completed), + LeaseReleaseKind.PartiallyReleased or LeaseReleaseKind.CleanupUnconfirmed => + Outcome(kind, CheatEngineHostEffect.Started), + LeaseReleaseKind.AlreadyReleased or LeaseReleaseKind.Replaced or LeaseReleaseKind.Superseded + or LeaseReleaseKind.ExternallyRemoved or LeaseReleaseKind.RefusedTargetNotAttached + or LeaseReleaseKind.RefusedTargetChanged or LeaseReleaseKind.RefusedTargetIdentityUnavailable + or LeaseReleaseKind.RefusedRuntimeChanged or LeaseReleaseKind.CleanupUnavailable => + Outcome(kind, CheatEngineHostEffect.NotStarted), + _ => Outcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown) + }; + } + + private static LeaseReleaseOutcome Outcome(LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + return new LeaseReleaseOutcome(kind, hostEffect); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs b/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs index 16e0a12..75aa363 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs @@ -1,5 +1,5 @@ +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Lua; -using CheatEngine.Client.Results; namespace CheatEngine.Client.Core.Domains; @@ -23,8 +23,8 @@ public void ThrowIfExpired() return; } - throw new CheatEngineActivationExpiredException( - "Lua.OperationContext", + throw ClientExceptions.ActivationExpired( + "Lua.Execute", "The Lua operation context is no longer valid for the current Cheat Engine activation."); } diff --git a/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs b/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs deleted file mode 100644 index 52ae412..0000000 --- a/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CheatEngine.Client.Core.Infrastructure; - -namespace CheatEngine.Client.Core.Domains; - -internal sealed class LuaRuntimeProbe : IRuntimeProbe -{ - public double GetCheatEngineVersion() - { - return ClientLuaGlobals.GetCheatEngineVersion(); - } - - public int GetSystemArchitecture() - { - return ClientLuaGlobals.GetSystemArchitecture(); - } - - public int GetTargetAbi() - { - return ClientLuaGlobals.GetTargetAbi(); - } - - public long GetOpenedProcessId() - { - return ClientLuaGlobals.GetOpenedProcessId(); - } - - public bool TargetIs64Bit() - { - return ClientLuaGlobals.TargetIs64Bit(); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryAccessFailureMapping.cs b/libs/CheatEngine.Client.Core/Domains/MemoryAccessFailureMapping.cs new file mode 100644 index 0000000..14b873d --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/MemoryAccessFailureMapping.cs @@ -0,0 +1,151 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Memory; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Maps every that a CheatEngine.SDK 2.0.0 TargetMemory operation reports to +/// the Client vocabulary, value by value (audit AUD-08, ADR-08). +/// +/// +/// +/// +/// SDK failure +/// Client failure kind and host effect +/// +/// +/// GlobalUnavailable +/// CapabilityUnavailable, NotStarted: the Cheat Engine global was not called. +/// +/// LuaErrorLuaError, Unknown +/// ReadFailedMemoryReadFailed, Unknown +/// +/// PartialRead +/// MemoryReadFailed, Unknown; the byte read reports the confirmed prefix length. +/// +/// DestinationTooSmallResultLimitExceeded, Unknown +/// +/// PointerWidthUnknown +/// InvalidState, NotStarted: CheatEngine.SDK refuses before calling Cheat Engine. +/// +/// +/// PointerValueExceedsTargetWidth +/// +/// OperationRejected. A pointer write is refused before Cheat Engine is called (NotStarted); +/// a pointer read is refused after Cheat Engine returned a value wider than the observed target width +/// (Completed), and nothing is published. +/// +/// +/// WriteFailedMemoryWriteFailed, Unknown +/// InvalidResultInvalidHostResult, Unknown +/// +/// None on a failed call, or an undefined value +/// +/// IndeterminateHostResult, Unknown: a failure without a recognized cause is never a success +/// and never an established outcome. +/// +/// +/// +/// +/// Messages are stable and name the category only, never an address or a value. The mapping-totality tests fail +/// when the consumed SDK adds a value. +/// +/// +internal static class MemoryAccessFailureMapping +{ + /// Returns the Client failure kind of a failed SDK memory access. + /// The SDK failure; reports a failure without a cause. + /// The failure kind; for an unrecognized value. + internal static CheatEngineFailureKind ToFailureKind(MemoryAccessFailure failure) + { + return failure switch + { + MemoryAccessFailure.None => CheatEngineFailureKind.IndeterminateHostResult, + MemoryAccessFailure.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + MemoryAccessFailure.LuaError => CheatEngineFailureKind.LuaError, + MemoryAccessFailure.ReadFailed => CheatEngineFailureKind.MemoryReadFailed, + MemoryAccessFailure.PartialRead => CheatEngineFailureKind.MemoryReadFailed, + MemoryAccessFailure.DestinationTooSmall => CheatEngineFailureKind.ResultLimitExceeded, + MemoryAccessFailure.PointerWidthUnknown => CheatEngineFailureKind.InvalidState, + MemoryAccessFailure.PointerValueExceedsTargetWidth => CheatEngineFailureKind.OperationRejected, + MemoryAccessFailure.WriteFailed => CheatEngineFailureKind.MemoryWriteFailed, + MemoryAccessFailure.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Returns what a failed SDK memory access establishes about the Cheat Engine side effect. + /// The SDK failure. + /// Whether the access was a write. + /// The host effect; unless the failure proves more. + internal static CheatEngineHostEffect ToHostEffect(MemoryAccessFailure failure, bool isWrite) + { + return failure switch + { + MemoryAccessFailure.GlobalUnavailable => CheatEngineHostEffect.NotStarted, + MemoryAccessFailure.PointerWidthUnknown => CheatEngineHostEffect.NotStarted, + MemoryAccessFailure.PointerValueExceedsTargetWidth => isWrite + ? CheatEngineHostEffect.NotStarted + : CheatEngineHostEffect.Completed, + _ => CheatEngineHostEffect.Unknown + }; + } + + /// Creates the failure of one failed SDK memory access. + /// The public Client operation name. + /// The SDK failure. + /// Whether the access was a write. + /// The classified failure, whose message names the category only. + internal static CheatEngineFailure ToFailure(string operation, MemoryAccessFailure failure, bool isWrite) + { + return new CheatEngineFailure(ToFailureKind(failure), operation, Describe(failure, isWrite), null, + ToHostEffect(failure, isWrite)); + } + + /// + /// Creates the failure of a byte read that did not complete. A names + /// the confirmed prefix length; any other failure keeps its own mapping. + /// + /// The public Client operation name. + /// The SDK failure. + /// The number of bytes CheatEngine.SDK verified and copied. + /// The number of bytes requested. + /// The classified failure; its message names counts only. + internal static CheatEngineFailure ToByteReadFailure(string operation, MemoryAccessFailure failure, + int confirmedLength, int requestedLength) + { + CheatEngineFailure mapped = ToFailure(operation, failure, false); + return failure == MemoryAccessFailure.PartialRead + ? new CheatEngineFailure(mapped.Kind, operation, + $"Cheat Engine returned only the first {confirmedLength} of the {requestedLength} requested target bytes.", + null, mapped.HostEffect) + : mapped; + } + + /// Describes a failed SDK memory access without naming an address or a value. + /// The SDK failure. + /// Whether the access was a write. + /// A stable message. + internal static string Describe(MemoryAccessFailure failure, bool isWrite) + { + return failure switch + { + MemoryAccessFailure.GlobalUnavailable => + "The Cheat Engine memory global is unavailable, so Cheat Engine was not called.", + MemoryAccessFailure.LuaError => "The protected Cheat Engine memory call raised a Lua error.", + MemoryAccessFailure.ReadFailed => "Cheat Engine could not read the target memory.", + MemoryAccessFailure.PartialRead => "Cheat Engine returned only part of the requested target bytes.", + MemoryAccessFailure.DestinationTooSmall => + "The value Cheat Engine returned is larger than the destination the Client provided.", + MemoryAccessFailure.PointerWidthUnknown => + "The target pointer width was not observed, so the pointer operation was refused before Cheat Engine " + + "was called.", + MemoryAccessFailure.PointerValueExceedsTargetWidth => isWrite + ? "The pointer value does not fit the observed pointer width of the target; nothing was written." + : "Cheat Engine returned a pointer that does not fit the observed pointer width of the target.", + MemoryAccessFailure.WriteFailed => "Cheat Engine rejected the target-memory write.", + MemoryAccessFailure.InvalidResult => "Cheat Engine returned a memory result outside its documented shape.", + _ => "Cheat Engine reported a failed memory access without a recognized cause." + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs index baf5c4b..de031f2 100644 --- a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs @@ -10,12 +10,22 @@ using CheatEngine.Client.Memory; using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; -internal sealed class MemoryClient : IMemoryClient, IMemoryBatchClient +internal sealed class MemoryClient : IMemoryClient { + /// The effect state reported for a read batch, which never changes the target. + private const string ReadBatchEffectState = "ReadOnly"; + + /// The operation name of a codec read, and of every failure its codec context raises. + private const string ReadOperation = "Memory.Read"; + + /// The operation name of a codec write, and of every failure its codec context raises. + private const string WriteOperation = "Memory.Write"; + private readonly IMemoryCodecContextPort _codecContextPort; private readonly ICheatEngineDispatcher _dispatcher; @@ -49,77 +59,170 @@ internal MemoryClient( _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _codecContextPort = codecContextPort ?? throw new ArgumentNullException(nameof(codecContextPort)); - _limits = (limits ?? throw new ArgumentNullException(nameof(limits))).CreateSnapshot(); + _limits = MemoryResourceLimitsCopy.CreateValidated(limits); } public MemoryPrimitiveBatchReadOutcome ReadPrimitiveBatchDetailed(MemoryPrimitiveBatchReadRequest request, CancellationToken cancellationToken = default) + where T : unmanaged + { + MemoryPrimitiveBatchReadOutcome outcome = ReadPrimitiveBatchCore(request, cancellationToken); + // Counts only, never an address or a value (A24-17); a read batch has no target effect. + _lifetime.Diagnostics.MemoryBatchCompleted("Memory.ReadPrimitiveBatch", outcome.RequestedCount, + outcome.CompletedCount, ReadBatchEffectState); + return outcome; + } + + public MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchDetailed(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken = default) + where T : unmanaged + { + MemoryPrimitiveBatchWriteOutcome outcome = WritePrimitiveBatchCore(request, cancellationToken); + _lifetime.Diagnostics.MemoryBatchCompleted("Memory.WritePrimitiveBatch", outcome.RequestedCount, + outcome.CompletedCount, outcome.EffectState.ToString()); + return outcome; + } + + private MemoryPrimitiveBatchReadOutcome ReadPrimitiveBatchCore(MemoryPrimitiveBatchReadRequest request, + CancellationToken cancellationToken) + where T : unmanaged { ValidateBatch(request.Addresses, nameof(request)); - int attemptedCount = request.Addresses.Length; - if (!TryAdmitBatch(attemptedCount, false, "Memory.ReadPrimitiveBatch", - out CheatEngineFailure admissionFailure)) + int requestedCount = request.Addresses.Length; + ThrowIfDispatchRefused("Memory.ReadPrimitiveBatch"); + if (!PrimitiveMemoryCodec.IsSupported) + { + return new MemoryPrimitiveBatchReadOutcome(requestedCount, [], null, + UnsupportedPrimitive("Memory.ReadPrimitiveBatch")); + } + + if (!TryAdmitBatch(requestedCount, false, "Memory.ReadPrimitiveBatch", + out CheatEngineFailure admissionFailure)) { - return new MemoryPrimitiveBatchReadOutcome(attemptedCount, 0, null, admissionFailure, []); + return new MemoryPrimitiveBatchReadOutcome(requestedCount, [], null, admissionFailure); } PrimitiveBatchReadInput input = new(request, _codecContextPort); if (!TryInvoke(input, static current => PrimitiveMemoryCodec.ReadBatch(current.Request, current.Port), - out PrimitiveBatchReadOutcome outcome, out CheatEngineFailure dispatchFailure, cancellationToken)) + out PrimitiveBatchReadOutcome outcome, out CheatEngineFailure dispatchFailure, cancellationToken)) { - return new MemoryPrimitiveBatchReadOutcome(attemptedCount, 0, null, dispatchFailure, []); + return new MemoryPrimitiveBatchReadOutcome(requestedCount, [], null, dispatchFailure); + } + + if (outcome.WidthRefusal is { } widthRefusal) + { + // The whole Address batch is refused before its first read (PointerWidthPolicy). + return new MemoryPrimitiveBatchReadOutcome(requestedCount, [], null, + RefuseWidth("Memory.ReadPrimitiveBatch", widthRefusal)); } if (outcome.Succeeded) { - return new MemoryPrimitiveBatchReadOutcome(attemptedCount, attemptedCount, null, null, outcome.Values); + return new MemoryPrimitiveBatchReadOutcome(requestedCount, outcome.Values, null, null); } - CheatEngineFailure failure = - CreateBatchFailure(outcome.Handled, false, outcome.FailedIndex, outcome.Failure); - return new MemoryPrimitiveBatchReadOutcome(attemptedCount, outcome.FailedIndex, outcome.FailedIndex, - failure, outcome.Values); + if (outcome.Fault is { } admissionFault && outcome.FailedIndex < 0) + { + // The target observation of an Address batch faulted before its first read. + return new MemoryPrimitiveBatchReadOutcome(requestedCount, [], null, + SdkBoundary.Translate("Memory.ReadPrimitiveBatch", admissionFault, CheatEngineHostEffect.Unknown, + _lifetime)); + } + + CheatEngineFailure failure = outcome.Fault is { } fault + ? SdkBoundary.Translate("Memory.ReadPrimitiveBatch", fault, CheatEngineHostEffect.Unknown, _lifetime) + : CreateBatchFailure(false, outcome.FailedIndex, outcome.Failure); + return new MemoryPrimitiveBatchReadOutcome(requestedCount, outcome.Values, outcome.FailedIndex, + failure); } - public MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchDetailed(MemoryPrimitiveBatchWriteRequest request, - CancellationToken cancellationToken = default) + private MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchCore(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken) + where T : unmanaged { ValidateBatch(request.Values, nameof(request)); - int attemptedCount = request.Values.Length; - if (!TryAdmitBatch(attemptedCount, true, "Memory.WritePrimitiveBatch", - out CheatEngineFailure admissionFailure)) + int requestedCount = request.Values.Length; + ThrowIfDispatchRefused("Memory.WritePrimitiveBatch"); + if (!PrimitiveMemoryCodec.IsSupported) + { + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, 0, null, + UnsupportedPrimitive("Memory.WritePrimitiveBatch"), MemoryBatchWriteEffectState.NotStarted); + } + + if (!TryAdmitBatch(requestedCount, true, "Memory.WritePrimitiveBatch", + out CheatEngineFailure admissionFailure)) { - return new MemoryPrimitiveBatchWriteOutcome(attemptedCount, 0, null, admissionFailure, + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, 0, null, admissionFailure, MemoryBatchWriteEffectState.NotStarted); } PrimitiveBatchWriteInput input = new(request, _codecContextPort); if (!TryInvoke(input, static current => PrimitiveMemoryCodec.WriteBatch(current.Request, current.Port), - out PrimitiveBatchWriteOutcome outcome, out CheatEngineFailure dispatchFailure, cancellationToken)) + out PrimitiveBatchWriteOutcome outcome, out CheatEngineFailure dispatchFailure, cancellationToken)) { - return new MemoryPrimitiveBatchWriteOutcome(attemptedCount, 0, null, dispatchFailure, - MemoryBatchWriteEffectState.Unknown); + return CreateDispatchFailureOutcome(requestedCount, dispatchFailure); + } + + if (outcome.WidthRefusal is { } widthRefusal) + { + // The whole Address batch is refused before its first write (PointerWidthPolicy): nothing was written. + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, 0, null, + RefuseWidth("Memory.WritePrimitiveBatch", widthRefusal), + MemoryBatchWriteEffectState.NotStarted); } if (outcome.Succeeded) { - return new MemoryPrimitiveBatchWriteOutcome(attemptedCount, attemptedCount, null, null, - MemoryBatchWriteEffectState.Complete); + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, requestedCount, null, null, + MemoryBatchWriteEffectState.Completed); + } + + if (outcome.Fault is { } admissionFault && outcome.FailedIndex < 0) + { + // The target observation of an Address batch faulted before its first write: nothing was written. + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, 0, null, + SdkBoundary.Translate("Memory.WritePrimitiveBatch", admissionFault, CheatEngineHostEffect.NotStarted, + _lifetime), MemoryBatchWriteEffectState.NotStarted); + } + + if (outcome.Fault is { } fault) + { + // The write at FailedIndex threw inside the SDK: whether it reached the target cannot be established. + CheatEngineFailure faultFailure = SdkBoundary.Translate("Memory.WritePrimitiveBatch", fault, + CheatEngineHostEffect.Unknown, _lifetime); + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, outcome.FailedIndex, outcome.FailedIndex, + faultFailure, MemoryBatchWriteEffectState.Unknown); } - CheatEngineFailure failure = CreateBatchFailure(outcome.Handled, true, outcome.FailedIndex, outcome.Failure); + CheatEngineFailure failure = CreateBatchFailure(true, outcome.FailedIndex, outcome.Failure); MemoryBatchWriteEffectState effectState = outcome.FailedIndex == 0 ? MemoryBatchWriteEffectState.NotStarted : MemoryBatchWriteEffectState.Partial; - return new MemoryPrimitiveBatchWriteOutcome(attemptedCount, outcome.FailedIndex, outcome.FailedIndex, failure, + if (effectState == MemoryBatchWriteEffectState.Partial) + { + // A completed prefix persists and is never rolled back. + failure = CoreFailureFactory.WithHostEffect(failure, CheatEngineHostEffect.Started); + } + + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, outcome.FailedIndex, outcome.FailedIndex, failure, effectState); } public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { - if (!TryInvoke(address, static current => PrimitiveMemoryCodec.Read(current), - out PrimitiveReadOutcome outcome, out failure, cancellationToken)) + ThrowIfDispatchRefused("Memory.ReadPrimitive"); + if (!PrimitiveMemoryCodec.IsSupported) + { + value = default; + failure = UnsupportedPrimitive("Memory.ReadPrimitive"); + return false; + } + + PrimitiveReadInput input = new(address, _codecContextPort); + if (!TryInvoke(input, static current => PrimitiveMemoryCodec.Read(current.Port, current.Address), + out PrimitiveReadOutcome outcome, out failure, cancellationToken)) { value = default; return false; @@ -128,11 +231,11 @@ public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T va if (!outcome.Succeeded) { value = default; - failure = !outcome.Handled - ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.ReadPrimitive", - $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") - : new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitive", - outcome.Failure ?? "Cheat Engine rejected the target-memory read."); + failure = outcome.WidthRefusal is { } widthRefusal + ? RefuseWidth("Memory.ReadPrimitive", widthRefusal) + : outcome.Fault is { } fault + ? SdkBoundary.Translate("Memory.ReadPrimitive", fault, CheatEngineHostEffect.Unknown, _lifetime) + : MemoryAccessFailureMapping.ToFailure("Memory.ReadPrimitive", outcome.Failure, false); return false; } @@ -142,33 +245,43 @@ public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T va } public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + where T : unmanaged { - if (TryReadPrimitive(address, out T? value, out CheatEngineFailure failure, cancellationToken)) + if (TryReadPrimitive(address, out T value, out CheatEngineFailure failure, cancellationToken)) { - return value!; + return value; } - failure.Throw(); - return default!; + failure.Throw(cancellationToken); + return default; } public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { - PrimitiveWriteInput input = new(address, value); - if (!TryInvoke(input, static current => PrimitiveMemoryCodec.Write(current.Address, current.Value), - out PrimitiveWriteOutcome outcome, out failure, cancellationToken)) + ThrowIfDispatchRefused("Memory.WritePrimitive"); + if (!PrimitiveMemoryCodec.IsSupported) + { + failure = UnsupportedPrimitive("Memory.WritePrimitive"); + return false; + } + + PrimitiveWriteInput input = new(address, value, _codecContextPort); + if (!TryInvoke(input, + static current => PrimitiveMemoryCodec.Write(current.Port, current.Address, current.Value), + out PrimitiveWriteOutcome outcome, out failure, cancellationToken)) { return false; } if (!outcome.Succeeded) { - failure = !outcome.Handled - ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.WritePrimitive", - $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") - : new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.WritePrimitive", - outcome.Failure ?? "Cheat Engine rejected the target-memory write."); + failure = outcome.WidthRefusal is { } widthRefusal + ? RefuseWidth("Memory.WritePrimitive", widthRefusal) + : outcome.Fault is { } fault + ? SdkBoundary.Translate("Memory.WritePrimitive", fault, CheatEngineHostEffect.Unknown, _lifetime) + : MemoryAccessFailureMapping.ToFailure("Memory.WritePrimitive", outcome.Failure, true); return false; } @@ -177,83 +290,88 @@ public bool TryWritePrimitive(Address address, T value, out CheatEngineFailur } public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + where T : unmanaged { if (!TryWritePrimitive(address, value, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, out ImmutableArray values, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { MemoryPrimitiveBatchReadOutcome outcome = ReadPrimitiveBatchDetailed(request, cancellationToken); - if (outcome.Succeeded) + if (outcome.Failure is { } readFailure) { - values = outcome.ReadPrefix; - failure = default; - return true; + values = []; + failure = readFailure; + return false; } - values = []; - failure = outcome.Cause!.Value; - return false; + values = outcome.Values; + failure = default; + return true; } public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, CancellationToken cancellationToken = default) + where T : unmanaged { if (TryReadPrimitiveBatch(request, out ImmutableArray values, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return values; } - failure.Throw(); + failure.Throw(cancellationToken); return []; } public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { MemoryPrimitiveBatchWriteOutcome outcome = WritePrimitiveBatchDetailed(request, cancellationToken); - if (outcome.Succeeded) + if (outcome.Failure is { } writeFailure) { - failure = default; - return true; + failure = writeFailure; + return false; } - failure = outcome.Cause!.Value; - return false; + failure = default; + return true; } public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, CancellationToken cancellationToken = default) + where T : unmanaged { if (!TryWritePrimitiveBatch(request, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateCodec(request.Codec, nameof(request)); + ThrowIfDispatchRefused(ReadOperation); T? captured = default; - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => succeeded = TryReadCore(request, out captured, out hostFailure), - out failure, cancellationToken)) + CodecOutcome outcome = default; + if (!_dispatcher.TryInvoke(() => outcome = TryReadCore(request, out captured), out failure, + cancellationToken)) { value = default; return false; } - if (!succeeded) + if (!outcome.Succeeded) { value = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.Read", - hostFailure ?? "Cheat Engine rejected the target-memory read."); + failure = CreateCodecFailure(outcome, false, ReadOperation); return false; } @@ -269,25 +387,24 @@ public T Read(MemoryReadRequest request, CancellationToken cancellationTok return value; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => succeeded = TryWriteCore(request, out hostFailure), - out failure, cancellationToken)) + ValidateCodec(request.Codec, nameof(request)); + ThrowIfDispatchRefused(WriteOperation); + CodecOutcome outcome = default; + if (!_dispatcher.TryInvoke(() => outcome = TryWriteCore(request), out failure, cancellationToken)) { return false; } - if (!succeeded) + if (!outcome.Succeeded) { - failure = new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.Write", - hostFailure ?? "Cheat Engine rejected the target-memory write."); + failure = CreateCodecFailure(outcome, true, WriteOperation); return false; } @@ -299,44 +416,25 @@ public void Write(MemoryWriteRequest request, CancellationToken cancellati { if (!TryWrite(request, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Length); - if (!TryAdmitPayload(request.Length, _limits.MaximumReadBytes, false, "Memory.ReadBytes", "byte read", - out failure)) + MemoryBytesReadOutcome outcome = ReadBytesDetailed(request, cancellationToken); + if (outcome.Failure is { } readFailure) { + // The Try form publishes all or nothing; ReadBytesDetailed keeps the confirmed prefix. bytes = []; + failure = readFailure; return false; } - ImmutableArray captured = ImmutableArray.Empty; - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - byte[] buffer = new byte[request.Length]; - succeeded = TargetMemory.TryReadBytes(request.Address, buffer, out MemoryAccessFailure sdkFailure); - if (succeeded) - { - captured = ImmutableCollectionsMarshal.AsImmutableArray(buffer); - } - else - { - hostFailure = sdkFailure.ToString(); - } - }, out failure, cancellationToken)) - { - bytes = []; - return false; - } - - bytes = captured; - return TryMapMemoryFailure(succeeded, false, "Memory.ReadBytes", hostFailure, out failure); + bytes = outcome.Bytes; + failure = default; + return true; } public ImmutableArray ReadBytes(MemoryBytesReadRequest request, @@ -347,10 +445,58 @@ public ImmutableArray ReadBytes(MemoryBytesReadRequest request, return bytes; } - failure.Throw(); + failure.Throw(cancellationToken); return []; } + /// + /// Reads through CheatEngine.SDK's counted TargetMemory.TryReadBytes: every byte it verified before a + /// shorter or malformed result is kept as the confirmed prefix, and a PartialRead names its length. + /// + public MemoryBytesReadOutcome ReadBytesDetailed(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Length); + ThrowIfDispatchRefused("Memory.ReadBytes"); + if (!TryAdmitPayload(request.Length, _limits.MaximumReadBytes, false, "Memory.ReadBytes", "byte read", + out CheatEngineFailure failure)) + { + return new MemoryBytesReadOutcome(request.Length, [], failure); + } + + byte[] buffer = []; + int written = 0; + HostCall call = default; + if (!_dispatcher.TryInvoke(() => + { + buffer = new byte[request.Length]; + call = HostCall.Run(_codecContextPort, buffer, request.Address, + (port, destination, address, out hostFailure) => + port.TryReadBytes(address, destination, out written, out hostFailure)); + }, out failure, cancellationToken)) + { + return new MemoryBytesReadOutcome(request.Length, [], failure); + } + + if (call.Succeeded) + { + return new MemoryBytesReadOutcome(request.Length, ImmutableCollectionsMarshal.AsImmutableArray(buffer), + null); + } + + if (call.Fault is { } fault) + { + return new MemoryBytesReadOutcome(request.Length, [], + SdkBoundary.Translate("Memory.ReadBytes", fault, CheatEngineHostEffect.Unknown, _lifetime)); + } + + // A count outside [0, Length) cannot be a verified prefix of a failed read: publish none of it. + int confirmed = written > 0 && written < request.Length ? written : 0; + return new MemoryBytesReadOutcome(request.Length, ImmutableArray.Create(buffer, 0, confirmed), + MemoryAccessFailureMapping.ToByteReadFailure("Memory.ReadBytes", call.HostFailure, confirmed, + request.Length)); + } + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -359,35 +505,30 @@ public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailur throw new ArgumentException("At least one byte is required.", nameof(request)); } + ThrowIfDispatchRefused("Memory.WriteBytes"); if (!TryAdmitPayload(request.Bytes.Length, _limits.MaximumWriteBytes, true, "Memory.WriteBytes", "byte write", - out failure)) + out failure)) { return false; } - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - succeeded = TargetMemory.TryWriteBytes(request.Address, request.Bytes.AsSpan(), - out MemoryAccessFailure sdkFailure); - if (!succeeded) - { - hostFailure = sdkFailure.ToString(); - } - }, out failure, cancellationToken)) + HostCall call = default; + if (!_dispatcher.TryInvoke(() => call = HostCall.Run(_codecContextPort, request, request.Address, + static (port, current, address, out hostFailure) => + port.TryWriteBytes(address, current.Bytes.AsSpan(), out hostFailure)), + out failure, cancellationToken)) { return false; } - return TryMapMemoryFailure(succeeded, true, "Memory.WriteBytes", hostFailure, out failure); + return TryMapMemoryFailure(call, true, "Memory.WriteBytes", out failure); } public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default) { if (!TryWriteBytes(request, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -395,32 +536,28 @@ public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] o out CheatEngineFailure failure, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumLength); - if (!TryAdmitPayload(GetEncodedByteLength(request.MaximumLength, request.WideCharacter), - _limits.MaximumStringBytes, false, "Memory.ReadString", "string read", out failure)) + ValidateEncoding(request.Encoding, nameof(request)); + ThrowIfDispatchRefused("Memory.ReadString"); + if (!TryAdmitPayload(GetEncodedByteLength(request.MaximumLength, request.Encoding == MemoryStringEncoding.Utf16), + _limits.MaximumStringBytes, false, "Memory.ReadString", "string read", out failure)) { value = null; return false; } string? captured = null; - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - succeeded = TargetMemory.TryReadString(request.Address, request.MaximumLength, - request.WideCharacter, out captured, out MemoryAccessFailure sdkFailure); - if (!succeeded) - { - hostFailure = sdkFailure.ToString(); - } - }, out failure, cancellationToken)) + HostCall call = default; + if (!_dispatcher.TryInvoke(() => call = HostCall.Run(_codecContextPort, request, request.Address, + (port, current, address, out hostFailure) => port.TryReadString(address, + current.MaximumLength, current.Encoding == MemoryStringEncoding.Utf16, out captured, out hostFailure)), + out failure, cancellationToken)) { value = null; return false; } - value = captured; - return TryMapMemoryFailure(succeeded, false, "Memory.ReadString", hostFailure, out failure); + value = call.Succeeded ? captured : null; + return TryMapMemoryFailure(call, false, "Memory.ReadString", out failure); } public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default) @@ -430,7 +567,7 @@ public string ReadString(MemoryStringReadRequest request, CancellationToken canc return value; } - failure.Throw(); + failure.Throw(cancellationToken); return string.Empty; } @@ -438,41 +575,39 @@ public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFail CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request.Value); - int encodedLength = GetEncodedLength(request.Value, request.WideCharacter); - if (request.MaximumLength > 0 && encodedLength > request.MaximumLength) + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumLength); + ValidateEncoding(request.Encoding, nameof(request)); + bool wideCharacter = request.Encoding == MemoryStringEncoding.Utf16; + int encodedLength = GetEncodedLength(request.Value, wideCharacter); + if (encodedLength > request.MaximumLength) { throw new ArgumentException("The encoded text exceeds the explicit maximum length.", nameof(request)); } - if (!TryAdmitPayload(GetEncodedByteLength(request.Value, request.WideCharacter), _limits.MaximumStringBytes, - true, "Memory.WriteString", "string write", out failure)) + ThrowIfDispatchRefused("Memory.WriteString"); + if (!TryAdmitPayload(GetEncodedByteLength(request.Value, wideCharacter), _limits.MaximumStringBytes, + true, "Memory.WriteString", "string write", out failure)) { return false; } - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - succeeded = TargetMemory.TryWriteString(request.Address, request.Value.AsSpan(), - request.WideCharacter, out MemoryAccessFailure sdkFailure); - if (!succeeded) - { - hostFailure = sdkFailure.ToString(); - } - }, out failure, cancellationToken)) + HostCall call = default; + if (!_dispatcher.TryInvoke(() => call = HostCall.Run(_codecContextPort, request, request.Address, + static (port, current, address, out hostFailure) => port.TryWriteString(address, + current.Value.AsSpan(), current.Encoding == MemoryStringEncoding.Utf16, out hostFailure)), + out failure, cancellationToken)) { return false; } - return TryMapMemoryFailure(succeeded, true, "Memory.WriteString", hostFailure, out failure); + return TryMapMemoryFailure(call, true, "Memory.WriteString", out failure); } public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default) { if (!TryWriteString(request, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -489,33 +624,88 @@ public bool TryResolvePointerChain(PointerChainRequest request, out Address addr throw new ArgumentOutOfRangeException(nameof(request), "A pointer chain is limited to 64 hops."); } + ThrowIfDispatchRefused("Memory.ResolvePointerChain"); Address captured = request.BaseAddress; - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - Address current = request.BaseAddress; - foreach (long offset in request.Offsets) - { - if (!TargetMemory.TryReadPointer(current, out Address pointer, out MemoryAccessFailure sdkFailure)) - { - hostFailure = sdkFailure.ToString(); - return; - } - - current = pointer + offset; - } - - captured = current; - succeeded = true; - }, out failure, cancellationToken)) + ObservedTarget? widthRefusal = null; + CheatEngineFailure? chainRefusal = null; + int failedHop = 0; + HostCall call = default; + if (!_dispatcher.TryInvoke(() => call = HostCall.Run(_codecContextPort, request, request.BaseAddress, + (port, current, baseAddress, out hostFailure) => + { + hostFailure = MemoryAccessFailure.None; + // Observe once, before the first hop: an unknown bitness or a configured/process width mismatch refuses + // the whole chain; every hop then reads through the SDK overload qualified by the observed bitness. + ObservedTarget facts = TargetArchitectureObserver.Observe(port); + if (!PointerWidthPolicy.IsAdmitted(facts)) + { + widthRefusal = facts; + return false; + } + + PointerSize width = facts.Bitness; + Address resolved = baseAddress; + if (!PointerWidthPolicy.Fits(resolved, width)) + { + chainRefusal = CreateChainAddressFailure(0, current.Offsets.Length); + return false; + } + + for (int hop = 0; hop < current.Offsets.Length; hop++) + { + if (!port.TryReadPointer(resolved, width, out Address pointer, out hostFailure)) + { + failedHop = hop + 1; + return false; + } + + // The SDK qualified the pointer value; the address the Client computes from it by adding the + // offset must still fit a 32-bit target, which would wrap it instead. + resolved = pointer + current.Offsets[hop]; + if (!PointerWidthPolicy.Fits(resolved, width)) + { + chainRefusal = CreateChainAddressFailure(hop + 1, current.Offsets.Length); + return false; + } + } + + captured = resolved; + return true; + }), out failure, cancellationToken)) { address = default; return false; } - address = captured; - return TryMapMemoryFailure(succeeded, false, "Memory.ResolvePointerChain", hostFailure, out failure); + address = default; + if (widthRefusal is { } refusal) + { + failure = RefuseWidth("Memory.ResolvePointerChain", refusal); + return false; + } + + if (chainRefusal is { } chainFailure) + { + failure = chainFailure; + return false; + } + + if (call.Succeeded) + { + address = captured; + failure = default; + return true; + } + + if (call.Fault is { } fault) + { + failure = SdkBoundary.Translate("Memory.ResolvePointerChain", fault, CheatEngineHostEffect.Unknown, + _lifetime); + return false; + } + + failure = CreateChainHopFailure(failedHop, request.Offsets.Length, call.HostFailure); + return false; } public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) @@ -525,7 +715,7 @@ public Address ResolvePointerChain(PointerChainRequest request, CancellationToke return address; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -542,21 +732,34 @@ private bool TryInvoke(TState state, Func call return _dispatcher.TryInvoke(() => callback(state), out result, out failure, cancellationToken); } - private bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, - out string? failure) + /// Runs a consumer codec read inside the dispatched callback. + /// + /// Exceptions thrown by the consumer codec are rethrown unchanged by the dispatcher. SDK faults raised by the + /// context's Client-internal port calls are captured by the context and reported as classified failures. + /// + private CodecOutcome TryReadCore(MemoryReadRequest request, out T? value) { - failure = null; TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(_lifetime, _dispatcher, _codecContextPort, - _limits); + _limits, ReadOperation); try { - if (request.Codec.TryRead(context, request.Address, out value)) + if (request.Codec.TryRead(context, request.Address, out value, out CheatEngineFailure codecFailure)) { - return true; + return CodecOutcome.Success; } - failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory read."; - return false; + // A codec that classified its failure has it published unchanged, its host effect included; a codec that + // returned the default failure is classified from what the context observed. + return codecFailure.IsDefault + ? context.CreateFailureOutcome($"The codec for '{typeof(T).Name}' rejected the target-memory read.") + : CodecOutcome.FromCodecFailure(codecFailure); + } + catch (Exception exception) when (context.IsContextFault(exception)) + { + // Only the exact exception instance this context threw is converted; any other codec exception, including + // an application-owned Client exception, is rethrown unchanged by the dispatcher. + value = default; + return context.CreateFailureOutcome(exception.Message); } finally { @@ -564,21 +767,25 @@ private bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] } } - private bool TryWriteCore(MemoryWriteRequest request, out string? failure) + private CodecOutcome TryWriteCore(MemoryWriteRequest request) { - failure = null; TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(_lifetime, _dispatcher, _codecContextPort, - _limits); + _limits, WriteOperation); try { T value = request.Value; - if (request.Codec.TryWrite(context, request.Address, in value)) + if (request.Codec.TryWrite(context, request.Address, in value, out CheatEngineFailure codecFailure)) { - return true; + return CodecOutcome.Success; } - failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory write."; - return false; + return codecFailure.IsDefault + ? context.CreateFailureOutcome($"The codec for '{typeof(T).Name}' rejected the target-memory write.") + : CodecOutcome.FromCodecFailure(codecFailure); + } + catch (Exception exception) when (context.IsContextFault(exception)) + { + return context.CreateFailureOutcome(exception.Message); } finally { @@ -586,6 +793,145 @@ private bool TryWriteCore(MemoryWriteRequest request, out string? failure) } } + private CheatEngineFailure CreateCodecFailure(CodecOutcome outcome, bool isWrite, string operation) + { + if (outcome.Classified is { } classified) + { + return classified; + } + + if (outcome.Fault is { } fault) + { + return SdkBoundary.Translate(operation, fault, CheatEngineHostEffect.Unknown, _lifetime); + } + + string message = outcome.Message ?? (isWrite + ? "Cheat Engine rejected the target-memory write." + : "Cheat Engine rejected the target-memory read."); + if (outcome.Kind is { } kind) + { + // A refusal recorded by the codec context itself (an unknown width, no target): nothing was accessed. + return new CheatEngineFailure(kind, operation, message, null, CheatEngineHostEffect.NotStarted); + } + + if (outcome.AccessFailure is { } access) + { + // The codec returned false after a context access that CheatEngine.SDK refused: keep the mapped kind. A codec + // may have made earlier accesses, so the host effect stays unknown. + return new CheatEngineFailure(MemoryAccessFailureMapping.ToFailureKind(access), operation, message); + } + + return new CheatEngineFailure( + isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, + operation, + message); + } + + /// + /// Creates the pointer-width refusal of an operation: an unknown bitness, or a configured/bitness mismatch that is + /// reported with the two widths only (EventId 1200). + /// + private CheatEngineFailure RefuseWidth(string operation, ObservedTarget facts) + { + if (facts.Bitness.IsKnown) + { + ReportWidthRefusal(operation, facts); + } + + return PointerWidthPolicy.CreateRefusal(operation, facts); + } + + private void ReportWidthRefusal(string operation, ObservedTarget facts) + { + _lifetime.Diagnostics.PointerWidthMismatchRefused(operation, facts.Bitness.Bytes, + facts.ConfiguredPointerSizeBytes ?? 0); + } + + /// + /// Creates the refusal of a chain address that does not fit a 32-bit target: the base address before any hop + /// (NotStarted), or the address computed after hop , whose reads all + /// returned (Completed; reads leave no effect in the target). + /// + private static CheatEngineFailure CreateChainAddressFailure(int completedHops, int hopCount) + { + return completedHops == 0 + ? new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Memory.ResolvePointerChain", + $"The pointer chain base address exceeds the 32-bit process width of the target; hop 1 of {hopCount} " + + "was not read.", null, CheatEngineHostEffect.NotStarted) + : new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Memory.ResolvePointerChain", + $"The address computed after pointer hop {completedHops} of {hopCount} exceeds the 32-bit process " + + "width of the target; the chain stopped before the next read.", null, CheatEngineHostEffect.Completed); + } + + /// + /// Creates the failure of the pointer read of hop (1-based) with its mapped kind; the hop + /// index is part of the message, never an address or a value. + /// + private static CheatEngineFailure CreateChainHopFailure(int hop, int hopCount, MemoryAccessFailure access) + { + CheatEngineFailure mapped = MemoryAccessFailureMapping.ToFailure("Memory.ResolvePointerChain", access, false); + return new CheatEngineFailure(mapped.Kind, mapped.Operation, + $"The pointer read of hop {hop} of {hopCount} failed: {mapped.Message}", null, mapped.HostEffect); + } + + /// + /// Creates the refusal of a primitive type outside the supported set (A5): OperationRejected and + /// NotStarted, before dispatch and without a Cheat Engine call. + /// + private static CheatEngineFailure UnsupportedPrimitive(string operation) + where T : unmanaged + { + return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + $"'{typeof(T).FullName}' is not a primitive the Client supports: use an 8- to 64-bit integer, float, double " + + "or Address, or pass a codec through a MemoryReadRequest or MemoryWriteRequest.", null, + CheatEngineHostEffect.NotStarted); + } + + private static MemoryPrimitiveBatchWriteOutcome CreateDispatchFailureOutcome(int requestedCount, + CheatEngineFailure dispatchFailure) + { + // ICheatEngineDispatcher observes cancellation before admission only, so a Cancelled dispatch failure proves that + // no callback ran and no write was attempted. Every other dispatch failure leaves the effect unknown. + if (dispatchFailure.Kind == CheatEngineFailureKind.Cancelled) + { + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, 0, null, + CoreFailureFactory.WithHostEffect(dispatchFailure, CheatEngineHostEffect.NotStarted), + MemoryBatchWriteEffectState.NotStarted); + } + + return new MemoryPrimitiveBatchWriteOutcome(requestedCount, 0, null, dispatchFailure, + MemoryBatchWriteEffectState.Unknown); + } + + /// + /// Throws for the default codec request, which carries no codec; without this check the codec call would fail + /// on Cheat Engine's main thread. Its constructor throws for a null codec + /// argument, but the request argument is not null: its missing codec is an . + /// + /// The request carries no codec. + private static void ValidateCodec(IMemoryCodec? codec, string parameterName) + { + if (codec is null) + { + throw new ArgumentException("A codec request must carry its codec; the default request has none.", + parameterName); + } + } + + /// + /// Throws for a tampered string request whose encoding is not a defined value, as its constructor does; without + /// this check any value other than UTF-16 would be read or written as UTF-8. + /// + /// The encoding is not a defined value. + private static void ValidateEncoding(MemoryStringEncoding encoding, string parameterName) + { + if (!Enum.IsDefined(encoding)) + { + throw new ArgumentOutOfRangeException(parameterName, encoding, + "A string request uses a defined encoding."); + } + } + private static void ValidateBatch(ImmutableArray values, string parameterName) { if (values.IsDefaultOrEmpty) @@ -593,23 +939,34 @@ private static void ValidateBatch(ImmutableArray values, string parameterN throw new ArgumentException("A memory batch requires at least one operation.", parameterName); } - if (values.Length > MemoryBatchLimits.MaximumOperations) + if (values.Length > MemoryBatchLimits.MaximumOperationCount) { throw new ArgumentOutOfRangeException(parameterName, - $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperationCount} operations."); } } - private bool TryAdmitBatch(int attemptedCount, bool isWrite, string operation, out CheatEngineFailure failure) + /// + /// Throws when the activation refuses dispatch, under the name of the public operation, after its arguments and + /// before the refusals decided without Cheat Engine (an unsupported type, a budget): an ended or stopping + /// activation throws before a refusal is reported, never the reverse. + /// + private void ThrowIfDispatchRefused(string operation) + { + _lifetime.ThrowIfDispatchRefused(operation); + } + + private bool TryAdmitBatch(int requestedCount, bool isWrite, string operation, out CheatEngineFailure failure) + where T : unmanaged { - if (attemptedCount > _limits.MaximumBatchOperationCount) + if (requestedCount > _limits.MaximumBatchOperationCount) { - failure = CreateLimitFailure(isWrite, operation, "batch operation count", attemptedCount, + failure = CreateLimitFailure(isWrite, operation, "batch operation count", requestedCount, _limits.MaximumBatchOperationCount); return false; } - return TryAdmitPayload(PrimitiveMemoryCodec.GetPayloadBytes(attemptedCount), + return TryAdmitPayload(PrimitiveMemoryCodec.GetPayloadBytes(requestedCount), _limits.MaximumBatchPayloadBytes, isWrite, operation, "batch payload", out failure); } @@ -632,24 +989,17 @@ private static CheatEngineFailure CreateLimitFailure(bool isWrite, string operat string direction = isWrite ? "write" : "read"; string unit = resource == "batch operation count" ? "operations" : "bytes"; return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, - $"The requested {resource} of {requestedBytes} {unit} exceeds the activation {direction} budget of {limit} {unit}."); + $"The requested {resource} of {requestedBytes} {unit} exceeds the activation {direction} budget of {limit} {unit}.", + null, CheatEngineHostEffect.NotStarted); } - private static CheatEngineFailure CreateBatchFailure(bool handled, bool isWrite, int failedIndex, - string? hostFailure) + private static CheatEngineFailure CreateBatchFailure(bool isWrite, int failedIndex, MemoryAccessFailure hostFailure) { string operation = isWrite ? "Memory.WritePrimitiveBatch" : "Memory.ReadPrimitiveBatch"; - if (!handled) - { - return new CheatEngineFailure(CheatEngineFailureKind.Unsupported, operation, - $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type."); - } - - CheatEngineFailureKind kind = - isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed; + CheatEngineFailure mapped = MemoryAccessFailureMapping.ToFailure(operation, hostFailure, isWrite); string action = isWrite ? "write" : "read"; - return new CheatEngineFailure(kind, operation, - $"The batch {action} failed at index {failedIndex}: {hostFailure ?? "Cheat Engine rejected the target-memory operation."}"); + return new CheatEngineFailure(mapped.Kind, operation, + $"The batch {action} failed at index {failedIndex}: {mapped.Message}", null, mapped.HostEffect); } private static int GetEncodedLength(string value, bool wideCharacter) @@ -667,52 +1017,157 @@ private static long GetEncodedByteLength(string value, bool wideCharacter) return wideCharacter ? (long) value.Length * sizeof(char) : Encoding.UTF8.GetByteCount(value); } - private static bool TryMapMemoryFailure(bool succeeded, bool isWrite, string operation, string? hostFailure, - out CheatEngineFailure failure) + private bool TryMapMemoryFailure(HostCall call, bool isWrite, string operation, out CheatEngineFailure failure) { - if (succeeded) + if (call.Succeeded) { failure = default; return true; } - failure = new CheatEngineFailure( - isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, - operation, - hostFailure ?? "Cheat Engine rejected the target-memory operation."); + if (call.Fault is { } fault) + { + failure = SdkBoundary.Translate(operation, fault, CheatEngineHostEffect.Unknown, _lifetime); + return false; + } + + failure = MemoryAccessFailureMapping.ToFailure(operation, call.HostFailure, isWrite); return false; } - private readonly record struct PrimitiveWriteInput(Address Address, T Value); + private readonly record struct PrimitiveReadInput(Address Address, IMemoryCodecContextPort Port); + + private readonly record struct PrimitiveWriteInput(Address Address, T Value, IMemoryCodecContextPort Port) + where T : unmanaged; - private readonly record struct PrimitiveReadOutcome(bool Handled, bool Succeeded, T Value, string? Failure); + private readonly record struct PrimitiveReadOutcome( + bool Succeeded, + T Value, + MemoryAccessFailure Failure, + Exception? Fault = null, + ObservedTarget? WidthRefusal = null) + where T : unmanaged; + + private readonly record struct PrimitiveWriteOutcome( + bool Succeeded, + MemoryAccessFailure Failure, + Exception? Fault = null, + ObservedTarget? WidthRefusal = null); + + /// + /// The pointer-width admission of an Address primitive path, observed once before any memory access: an unknown + /// bitness and a configured/bitness mismatch are refused, and an admitted path uses . + /// + private readonly record struct PointerWidthAdmission(PointerSize Width, ObservedTarget? Refusal, Exception? Fault) + { + internal bool IsAdmitted => Refusal is null && Fault is null; + + internal static PointerWidthAdmission Observe(IMemoryCodecContextPort port) + { + try + { + ObservedTarget facts = TargetArchitectureObserver.Observe(port); + return PointerWidthPolicy.IsAdmitted(facts) + ? new PointerWidthAdmission(facts.Bitness, null, null) + : new PointerWidthAdmission(PointerSize.Unknown, facts, null); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new PointerWidthAdmission(PointerSize.Unknown, null, exception); + } + } + } - private readonly record struct PrimitiveWriteOutcome(bool Handled, bool Succeeded, string? Failure); + /// The result of one Client-internal host call, with any SDK fault captured instead of thrown. + private readonly record struct HostCall(bool Succeeded, MemoryAccessFailure HostFailure, Exception? Fault) + { + internal static HostCall Run(IMemoryCodecContextPort port, TState state, Address address, + HostOperation operation) + { + try + { + return operation(port, state, address, out MemoryAccessFailure hostFailure) + ? new HostCall(true, MemoryAccessFailure.None, null) + : new HostCall(false, hostFailure, null); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new HostCall(false, MemoryAccessFailure.None, exception); + } + } + } + + private delegate bool HostOperation(IMemoryCodecContextPort port, TState state, Address address, + out MemoryAccessFailure hostFailure); + + /// + /// The result of a consumer codec call, distinguishing a codec refusal, an SDK fault, a refusal recorded by the + /// codec context itself () and a context access that CheatEngine.SDK refused + /// (). + /// + private readonly record struct CodecOutcome( + bool Succeeded, + string? Message, + Exception? Fault, + CheatEngineFailureKind? Kind = null, + MemoryAccessFailure? AccessFailure = null, + CheatEngineFailure? Classified = null) + { + internal static CodecOutcome Success => new(true, null, null); + + /// + /// Creates the outcome of a codec that returned with its own failure. + /// + /// The codec's classified failure, published unchanged. + internal static CodecOutcome FromCodecFailure(CheatEngineFailure failure) + { + return new CodecOutcome(false, null, null, Classified: failure); + } + } private readonly record struct PrimitiveBatchReadInput( MemoryPrimitiveBatchReadRequest Request, - IMemoryCodecContextPort Port); + IMemoryCodecContextPort Port) + where T : unmanaged; private readonly record struct PrimitiveBatchWriteInput( MemoryPrimitiveBatchWriteRequest Request, - IMemoryCodecContextPort Port); + IMemoryCodecContextPort Port) + where T : unmanaged; + /// + /// The outcome of a primitive batch inside the dispatched call; is negative when no + /// element failed (success, or a refusal or fault before the first element). + /// private readonly record struct PrimitiveBatchReadOutcome( - bool Handled, bool Succeeded, T[] Values, int FailedIndex, - string? Failure); - + MemoryAccessFailure Failure, + Exception? Fault = null, + ObservedTarget? WidthRefusal = null) + where T : unmanaged; + + /// + /// The outcome of a primitive batch inside the dispatched call; is negative when no + /// element failed (success, or a refusal or fault before the first element). + /// private readonly record struct PrimitiveBatchWriteOutcome( - bool Handled, bool Succeeded, int FailedIndex, - string? Failure); + MemoryAccessFailure Failure, + Exception? Fault = null, + ObservedTarget? WidthRefusal = null); + /// The supported primitive set (A5) and its SDK route, admitted before dispatch. private static class PrimitiveMemoryCodec + where T : unmanaged { - private static bool IsSupported => + /// + /// Gets whether is one of the supported primitives: the 8- to 64-bit integers, + /// , and . + /// + internal static bool IsSupported => typeof(T) == typeof(byte) || typeof(T) == typeof(sbyte) || typeof(T) == typeof(ushort) || @@ -725,38 +1180,97 @@ private static class PrimitiveMemoryCodec typeof(T) == typeof(double) || typeof(T) == typeof(Address); - internal static PrimitiveReadOutcome Read(Address address) - { - return Read(SdkMemoryCodecContextPort.Instance, address); - } + private static bool IsPointer => typeof(T) == typeof(Address); + /// + /// Reads one supported primitive; an SDK fault is captured, never thrown across a Try method. An Address read + /// is first admitted by and then qualified by the observed bitness. + /// internal static PrimitiveReadOutcome Read(IMemoryCodecContextPort port, Address address) { - if (!IsSupported) + PointerSize width = PointerSize.Unknown; + if (IsPointer) { - return new PrimitiveReadOutcome(false, false, default!, null); + PointerWidthAdmission admission = PointerWidthAdmission.Observe(port); + if (!admission.IsAdmitted) + { + return new PrimitiveReadOutcome(false, default!, MemoryAccessFailure.None, + admission.Fault, admission.Refusal); + } + + width = admission.Width; } - return port.TryReadPrimitive(address, out T value, out string? failure) - ? new PrimitiveReadOutcome(true, true, value, null) - : new PrimitiveReadOutcome(true, false, default!, failure); + return ReadElement(port, address, width); } - internal static PrimitiveWriteOutcome Write(Address address, T value) + private static PrimitiveReadOutcome ReadElement(IMemoryCodecContextPort port, Address address, + PointerSize width) { - return Write(SdkMemoryCodecContextPort.Instance, address, value); + try + { + bool succeeded; + T value; + MemoryAccessFailure failure; + if (IsPointer) + { + succeeded = port.TryReadPointer(address, width, out Address pointer, out failure); + value = Unsafe.As(ref pointer); + } + else + { + succeeded = port.TryReadPrimitive(address, out value, out failure); + } + + return succeeded + ? new PrimitiveReadOutcome(true, value, MemoryAccessFailure.None) + : new PrimitiveReadOutcome(false, default!, failure); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new PrimitiveReadOutcome(false, default!, MemoryAccessFailure.None, exception); + } } + /// + /// Writes one supported primitive; an SDK fault is captured, never thrown across a Try method. An Address + /// write is first admitted by and then qualified by the observed bitness. + /// internal static PrimitiveWriteOutcome Write(IMemoryCodecContextPort port, Address address, T value) { - if (!IsSupported) + PointerSize width = PointerSize.Unknown; + if (IsPointer) { - return new PrimitiveWriteOutcome(false, false, null); + PointerWidthAdmission admission = PointerWidthAdmission.Observe(port); + if (!admission.IsAdmitted) + { + return new PrimitiveWriteOutcome(false, MemoryAccessFailure.None, admission.Fault, + admission.Refusal); + } + + width = admission.Width; } - return port.TryWritePrimitive(address, value, out string? failure) - ? new PrimitiveWriteOutcome(true, true, null) - : new PrimitiveWriteOutcome(true, false, failure); + return WriteElement(port, address, value, width); + } + + private static PrimitiveWriteOutcome WriteElement(IMemoryCodecContextPort port, Address address, T value, + PointerSize width) + { + try + { + MemoryAccessFailure failure; + bool succeeded = IsPointer + ? port.TryWritePointer(address, Unsafe.As(ref value), width, out failure) + : port.TryWritePrimitive(address, value, out failure); + return succeeded + ? new PrimitiveWriteOutcome(true, MemoryAccessFailure.None) + : new PrimitiveWriteOutcome(false, failure); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new PrimitiveWriteOutcome(false, MemoryAccessFailure.None, exception); + } } internal static long GetPayloadBytes(int operationCount) @@ -767,61 +1281,78 @@ internal static long GetPayloadBytes(int operationCount) internal static PrimitiveBatchReadOutcome ReadBatch(MemoryPrimitiveBatchReadRequest request, IMemoryCodecContextPort port) { - if (!IsSupported) + PointerSize width = PointerSize.Unknown; + if (IsPointer) { - return new PrimitiveBatchReadOutcome(false, false, [], 0, null); + PointerWidthAdmission admission = PointerWidthAdmission.Observe(port); + if (!admission.IsAdmitted) + { + return new PrimitiveBatchReadOutcome(false, [], -1, MemoryAccessFailure.None, + admission.Fault, admission.Refusal); + } + + width = admission.Width; } T[] values = new T[request.Addresses.Length]; for (int index = 0; index < request.Addresses.Length; index++) { - PrimitiveReadOutcome current = Read(port, request.Addresses[index]); + PrimitiveReadOutcome current = ReadElement(port, request.Addresses[index], width); if (!current.Succeeded) { - return new PrimitiveBatchReadOutcome(current.Handled, false, values[..index], index, - current.Failure); + return new PrimitiveBatchReadOutcome(false, values[..index], index, + current.Failure, current.Fault); } values[index] = current.Value; } - return new PrimitiveBatchReadOutcome(true, true, values, -1, null); + return new PrimitiveBatchReadOutcome(true, values, -1, MemoryAccessFailure.None); } internal static PrimitiveBatchWriteOutcome WriteBatch(MemoryPrimitiveBatchWriteRequest request, IMemoryCodecContextPort port) { - if (!IsSupported) + PointerSize width = PointerSize.Unknown; + if (IsPointer) { - return new PrimitiveBatchWriteOutcome(false, false, 0, null); + PointerWidthAdmission admission = PointerWidthAdmission.Observe(port); + if (!admission.IsAdmitted) + { + return new PrimitiveBatchWriteOutcome(false, -1, MemoryAccessFailure.None, admission.Fault, + admission.Refusal); + } + + width = admission.Width; } for (int index = 0; index < request.Values.Length; index++) { MemoryAddressValue current = request.Values[index]; - PrimitiveWriteOutcome outcome = Write(port, current.Address, current.Value); + PrimitiveWriteOutcome outcome = WriteElement(port, current.Address, current.Value, width); if (!outcome.Succeeded) { - return new PrimitiveBatchWriteOutcome(outcome.Handled, false, index, outcome.Failure); + return new PrimitiveBatchWriteOutcome(false, index, outcome.Failure, outcome.Fault); } } - return new PrimitiveBatchWriteOutcome(true, true, -1, null); + return new PrimitiveBatchWriteOutcome(true, -1, MemoryAccessFailure.None); } } - private sealed class TargetMemoryCodecContext : IMemoryReadContext, IMemoryWriteContext + private sealed class TargetMemoryCodecContext + : IMemoryReadContext, IMemoryWriteContext { - private const string _operation = "Memory.CodecContext"; - private readonly long _activationEpoch; private readonly ICheatEngineDispatcher _dispatcher; private readonly CoreLifetime _lifetime; private readonly MemoryResourceLimits _limits; + private readonly string _operation; private readonly IMemoryCodecContextPort _port; private readonly int _threadId; + private Exception? _contextFault; private int _expired; - private int _pointerSize; + private ObservedTarget? _facts; private int _readBytesAdmitted; private int _writeBytesAdmitted; @@ -829,8 +1360,11 @@ private TargetMemoryCodecContext( CoreLifetime lifetime, ICheatEngineDispatcher dispatcher, IMemoryCodecContextPort port, - MemoryResourceLimits limits) + MemoryResourceLimits limits, + string operation) { + // A failure raised inside the codec names the public call that runs it (Memory.Read or Memory.Write). + _operation = operation; _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _port = port ?? throw new ArgumentNullException(nameof(port)); @@ -845,65 +1379,259 @@ internal string? Failure private set; } - public int PointerSize + /// Gets the SDK fault captured by the most recent failed context operation, if any. + internal Exception? Fault + { + get; + private set; + } + + /// Gets the failure kind of a refusal recorded by this context itself, if any. + internal CheatEngineFailureKind? FailureKind + { + get; + private set; + } + + /// Gets the SDK failure of the most recent context access that CheatEngine.SDK refused, if any. + internal MemoryAccessFailure? AccessFailure + { + get; + private set; + } + + /// + /// Gets the bitness of the selected target (the width Cheat Engine's readPointer uses), never the plugin's own + /// process width and never Cheat Engine's configured pointer size. An unknown bitness is recorded as the reason + /// a codec that then returns failed, without a throw. + /// + public PointerSize Bitness { get { ThrowIfUnusable(); - if (_pointerSize != 0) + ObservedTarget facts = ObserveFacts(); + if (!facts.Bitness.IsKnown) { - return _pointerSize; + RecordRefusal(PointerWidthPolicy.GetUnknownWidthKind(facts), + PointerWidthPolicy.CreateUnknownWidthMessage(facts)); } - int pointerSize = _port.IsTarget64Bit() ? sizeof(ulong) : sizeof(uint); - _pointerSize = pointerSize; - return pointerSize; + return facts.Bitness; + } + } + + public int? ConfiguredPointerSizeBytes + { + get + { + ThrowIfUnusable(); + return ObserveFacts().ConfiguredPointerSizeBytes; + } + } + + public PointerSize ConfiguredPointerSize + { + get + { + ThrowIfUnusable(); + return ObserveFacts().ConfiguredPointerSize; + } + } + + public bool? ConfiguredPointerSizeDiffersFromBitness + { + get + { + ThrowIfUnusable(); + ObservedTarget facts = ObserveFacts(); + return facts.ConfiguredPointerSizeBytes is { } configured && facts.Bitness.IsKnown + ? configured != facts.Bitness.Bytes + : null; } } - public bool TryReadBytes(Address address, Span destination) + public bool TryReadBytes(Address address, Span destination, out CheatEngineFailure failure) { ThrowIfUnusable(); if (!TryAdmitCodecBytes(destination.Length, _limits.MaximumReadBytes, ref _readBytesAdmitted, "read")) { + failure = DescribeLastFailure(false); return false; } - if (_port.TryReadBytes(address, destination, out string? failure)) + try + { + if (_port.TryReadBytes(address, destination, out int written, out MemoryAccessFailure access)) + { + ClearFailure(); + failure = default; + return true; + } + + // The codec asked for the exact buffer: a confirmed prefix is reported in the failure, never left behind + // as if it were data. + destination.Clear(); + SetAccessFailure(access, false); + if (access == MemoryAccessFailure.PartialRead) + { + Failure = MemoryAccessFailureMapping.ToByteReadFailure(_operation, access, + Math.Clamp(written, 0, destination.Length), destination.Length).Message; + } + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) { - Failure = null; - return true; + SetFailure(null, exception); } - Failure = failure; + failure = DescribeLastFailure(false); return false; } - public bool TryWriteBytes(Address address, ReadOnlySpan source) + public bool TryWriteBytes(Address address, ReadOnlySpan source, out CheatEngineFailure failure) { ThrowIfUnusable(); if (!TryAdmitCodecBytes(source.Length, _limits.MaximumWriteBytes, ref _writeBytesAdmitted, "write")) { + failure = DescribeLastFailure(true); return false; } - if (_port.TryWriteBytes(address, source, out string? failure)) + try { - Failure = null; - return true; + if (_port.TryWriteBytes(address, source, out MemoryAccessFailure access)) + { + ClearFailure(); + failure = default; + return true; + } + + SetAccessFailure(access, true); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + SetFailure(null, exception); } - Failure = failure; + failure = DescribeLastFailure(true); return false; } + /// Gets whether is the exact exception instance this context threw. + internal bool IsContextFault(Exception exception) + { + return _contextFault is not null && ReferenceEquals(exception, _contextFault); + } + + /// Creates the failure outcome of a codec that returned or threw a context fault. + internal CodecOutcome CreateFailureOutcome(string defaultMessage) + { + return new CodecOutcome(false, Failure ?? defaultMessage, Fault, FailureKind, AccessFailure); + } + + /// + /// Classifies what this context recorded for its last access, as and + /// hand it to the codec: an SDK fault by its classification, a refusal of the + /// context itself with , an access CheatEngine.SDK refused by its + /// mapped kind, and a codec budget refusal as a read or write failure that started nothing. + /// + private CheatEngineFailure DescribeLastFailure(bool isWrite) + { + string message = Failure ?? (isWrite + ? "Cheat Engine rejected the target-memory write." + : "Cheat Engine rejected the target-memory read."); + if (Fault is { } fault) + { + return SdkBoundary.Classify(_operation, fault, CheatEngineHostEffect.Unknown); + } + + if (FailureKind is { } kind) + { + return new CheatEngineFailure(kind, _operation, message, null, CheatEngineHostEffect.NotStarted); + } + + if (AccessFailure is { } access) + { + return new CheatEngineFailure(MemoryAccessFailureMapping.ToFailureKind(access), _operation, message); + } + + return new CheatEngineFailure( + isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, _operation, + message, null, CheatEngineHostEffect.NotStarted); + } + + /// + /// Observes the target facts once per codec invocation, PID first. The observer classifies SDK Engine and Lua + /// exceptions itself; any other SDK fault becomes a context fault that Core reports after the codec returns. + /// + private ObservedTarget ObserveFacts() + { + if (_facts is { } facts) + { + return facts; + } + + try + { + facts = TargetArchitectureObserver.Observe(_port); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + Fault = exception; + Failure = "Cheat Engine could not report the target pointer width."; + throw CreateContextFault(CoreFailureFactory.GetKind(exception), Failure, exception); + } + + _facts = facts; + return facts; + } + + private Exception CreateContextFault(CheatEngineFailureKind kind, string message, Exception? exception) + { + _contextFault = new CheatEngineFailure(kind, _operation, message, exception, CheatEngineHostEffect.NotStarted) + .ToException(); + return _contextFault; + } + + private void RecordRefusal(CheatEngineFailureKind kind, string message) + { + Failure = message; + Fault = null; + FailureKind = kind; + AccessFailure = null; + } + + private void SetFailure(string? failure, Exception? fault) + { + Failure = failure ?? (fault is null ? null : "Cheat Engine raised an SDK fault during the codec operation."); + Fault = fault; + FailureKind = null; + AccessFailure = null; + } + + /// Records a context access that CheatEngine.SDK refused with its own category, never its text. + private void SetAccessFailure(MemoryAccessFailure failure, bool isWrite) + { + SetFailure(MemoryAccessFailureMapping.Describe(failure, isWrite), null); + AccessFailure = failure; + } + + private void ClearFailure() + { + Failure = null; + Fault = null; + FailureKind = null; + AccessFailure = null; + } + internal static TargetMemoryCodecContext Create( CoreLifetime lifetime, ICheatEngineDispatcher dispatcher, IMemoryCodecContextPort port, - MemoryResourceLimits limits) + MemoryResourceLimits limits, + string operation) { - return new TargetMemoryCodecContext(lifetime, dispatcher, port, limits); + return new TargetMemoryCodecContext(lifetime, dispatcher, port, limits, operation); } internal void Expire() @@ -914,12 +1642,12 @@ internal void Expire() private void ThrowIfUnusable() { if (Volatile.Read(ref _expired) != 0 || - _activationEpoch != _lifetime.Epoch || - !_lifetime.IsActivationCurrent || - Environment.CurrentManagedThreadId != _threadId || - !_dispatcher.IsMainThread) + _activationEpoch != _lifetime.Epoch || + !_lifetime.IsActivationCurrent || + Environment.CurrentManagedThreadId != _threadId || + !_dispatcher.IsMainThread) { - throw new CheatEngineActivationExpiredException( + throw ClientExceptions.ActivationExpired( _operation, "The memory codec context is no longer valid for the current Cheat Engine invocation."); } @@ -931,7 +1659,8 @@ private bool TryAdmitCodecBytes(int requestedBytes, int limit, ref int admittedB { if (requestedBytes > limit - admittedBytes) { - Failure = $"The memory codec {direction} exceeds the activation {direction} budget of {limit} bytes."; + SetFailure($"The memory codec {direction} exceeds the activation {direction} budget of {limit} bytes.", + null); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryResourceLimitsCopy.cs b/libs/CheatEngine.Client.Core/Domains/MemoryResourceLimitsCopy.cs new file mode 100644 index 0000000..45b8eb3 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/MemoryResourceLimitsCopy.cs @@ -0,0 +1,25 @@ +using CheatEngine.Client.Memory; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Copies the mutable options into the activation's own validated value. +/// +/// +/// An activation-bound client keeps its own copy, so a later change to the options object never changes a running +/// client. The copy goes through the public five-argument constructor, which validates every bound. +/// +internal static class MemoryResourceLimitsCopy +{ + /// Creates an independently validated copy of . + /// The configured limits. + /// A copy that no caller holds. + /// is . + /// A limit is outside its documented range. + internal static MemoryResourceLimits CreateValidated(MemoryResourceLimits limits) + { + ArgumentNullException.ThrowIfNull(limits); + return new MemoryResourceLimits(limits.MaximumReadBytes, limits.MaximumWriteBytes, limits.MaximumStringBytes, + limits.MaximumBatchPayloadBytes, limits.MaximumBatchOperationCount); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs b/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs deleted file mode 100644 index 39afede..0000000 --- a/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CheatEngine.SDK.Engine.AddressList; - -namespace CheatEngine.Client.Core.Domains; - -/// One inspected link while validating that a record can be reparented without forming a cycle. -internal readonly record struct ParentChainStep(ParentChainStepKind Kind, MemoryRecordId ParentId) -{ - internal static ParentChainStep Root => new(ParentChainStepKind.Root, default); - - internal static ParentChainStep HostRejected => new(ParentChainStepKind.HostRejected, default); - - internal static ParentChainStep Parent(MemoryRecordId parentId) - { - return new ParentChainStep(ParentChainStepKind.Parent, parentId); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs b/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs deleted file mode 100644 index ff53736..0000000 --- a/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CheatEngine.Client.Core.Domains; - -/// Represents the outcome of reading one candidate-parent link. -internal enum ParentChainStepKind -{ - Root, - Parent, - HostRejected -} diff --git a/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs index 25aa216..e3f961b 100644 --- a/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs +++ b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs @@ -1,19 +1,61 @@ using System.Collections.Immutable; +using System.Diagnostics; using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; +/// Runs one Cheat Engine AOB scan on the route its scope allows and copies the matching addresses. +/// +/// +/// Routes and truthful cost (audit F07). A request without a module or range runs one global +/// AOBScan over the whole target (); +/// bounds only the copy. A request with a module and/or a range, on a +/// target that TargetSelection.ObserveCurrent qualifies, runs the SDK's bounded, exhaustive MemScan route +/// over the module intersected with the range (): Cheat Engine's +/// work is limited to those bounds, and the call blocks Cheat Engine's main thread for the scan, the copy and the +/// session release. When the target is not qualified, or the SDK cannot create the session or qualify the target, +/// the request falls back to the global scan with the module and range as managed post-filters +/// (). A cancellation token never interrupts a +/// Cheat Engine call that has started. reports the Cheat Engine scan time separately +/// from the copy time. +/// +/// +/// One scope rule on every route. A module keeps a match only when all of its pattern bytes lie inside +/// [BaseAddress, BaseAddress + ImageSize); a range keeps a match whose start lies in [Start, End]. +/// Both routes apply the same predicate () while copying, so the bounded route's +/// reliance on Cheat Engine honouring its stop bound becomes a Client guarantee, and the same request gives the +/// same addresses whichever route ran. Both routes copy at most +/// ScanResourceLimits.MaximumPatternMatches - 1 addresses. +/// +/// +/// Host outcomes (audit F06). classifies each outcome of both routes. On the +/// global route NoResult stays , because on +/// Cheat Engine 7.7 zero matches and host failures share that shape; only the bounded route reports a factual zero, +/// and only when Cheat Engine's error text was readable. A target that changed during a scan discards its answer. +/// +/// +/// Single release authority (audit F13). The owned result list of the global route is released exactly +/// once on every path, inside the dispatched callback, through the SDK's never-throwing +/// ReleaseWithOutcome; the SDK releases the bounded route's session itself and reports it. Any release +/// status but Released is : a release that was not +/// confirmed is never hidden behind a success. +/// +/// internal sealed class PatternScanner(SdkMainThreadDispatcher dispatcher, IAobScanPort? scanPort = null) : IPatternScanner { - private const int _maximumModuleSnapshot = 4096; - private const string _inModuleOperation = "Patterns.InModule"; - private const string _scanOperation = "Patterns.Scan"; + private const int MaximumModuleSnapshot = 4096; + private const string ScanOperation = "Patterns.Scan"; + private const string ListSubject = "AOB result list"; + private const string SessionSubject = "bounded AOB scan session"; private readonly SdkMainThreadDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); @@ -23,266 +65,782 @@ internal sealed class PatternScanner(SdkMainThreadDispatcher dispatcher, IAobSca public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - if (!TryValidateRequest(request, out failure)) + ScanOutcome outcome = Execute(request, cancellationToken); + result = outcome.Result; + failure = outcome.Failure; + return outcome.Succeeded; + } + + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) + { + if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) { - result = default; - return false; + return result; } - AobScanResult captured = default; - CheatEngineFailure hostFailure = default; - bool succeeded = false; - if (!_dispatcher.TryInvoke( - () => succeeded = TryScanCore(request, cancellationToken, out captured, out hostFailure), - out failure, cancellationToken)) + failure.Throw(cancellationToken); + return default; + } + + public PatternScanOutcome ScanDetailed(AobScanRequest request, CancellationToken cancellationToken = default) + { + ScanOutcome outcome = Execute(request, cancellationToken); + return outcome.Succeeded + ? new PatternScanOutcome(outcome.Result, null, outcome.Metrics, outcome.HostOutcome, outcome.RouteReason, + outcome.TargetIdentityVerified) + : new PatternScanOutcome(null, outcome.Failure, outcome.Metrics, outcome.HostOutcome, outcome.RouteReason, + false); + } + + /// + /// Throws for a request that its constructor would refuse: the request, or one whose + /// option was tampered with. Called before the activation check and before any Cheat Engine call. + /// + /// The request of the public call. + /// + /// The request has no pattern (the request), or its module filter is empty. + /// + /// + /// Its materialization limit is not positive, its range ends before it starts, or its protection filter or + /// alignment rule is not a defined value. + /// + internal static void ValidateRequest(AobScanRequest request) + { + if (string.IsNullOrWhiteSpace(request.Pattern.Value)) { - result = default; - return false; + throw new ArgumentException( + "An AOB scan requires a normalized, non-empty pattern; the default request has none.", nameof(request)); } - if (!succeeded) + if (request.MaximumResults <= 0) { - result = default; - failure = hostFailure; - return false; + throw new ArgumentOutOfRangeException(nameof(request), request.MaximumResults, + "An AOB scan requires a positive materialization limit."); } - result = captured; - failure = default; - return true; - } + if (request.Module is { } module && string.IsNullOrWhiteSpace(module.Value)) + { + throw new ArgumentException("An AOB module filter must be non-empty.", nameof(request)); + } - public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) - { - if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) + if (request.Range is { } range && range.End < range.Start) { - return result; + throw new ArgumentOutOfRangeException(nameof(request), range.End, + "An AOB range end address must not precede its start address."); } - failure.Throw(); - return default; + if (!ScanOptionTranslation.IsDefined(request.Protection) || !ScanOptionTranslation.IsDefined(request.Alignment)) + { + throw new ArgumentOutOfRangeException(nameof(request), + "An AOB protection filter or alignment rule is not a defined value."); + } } - private bool TryScanCore(AobScanRequest request, CancellationToken cancellationToken, out AobScanResult result, - out CheatEngineFailure failure) + /// + /// Builds the Cheat Engine work limit of the bounded route: the module's [BaseAddress, BaseAddress + + /// ImageSize) intersected with the range's [Start, End + pattern length). + /// + /// The resolved module, when the request names one. + /// The requested inclusive range of match starts, when the request has one. + /// The number of byte positions of the pattern. + /// The half-open bounds when the method returns . + /// The refusal otherwise; nothing was started. + /// + /// when the bounds cannot hold one whole match, or the module does not fit the address + /// space. + /// + /// + /// Both routes use these bounds as a precondition. Bounds shorter than the pattern hold no match start that + /// could keep (a range that ends before the module can hold a whole match, or a + /// module smaller than the pattern), so such a request is refused before any scan instead of costing a global scan + /// that can only return nothing. + /// + internal static bool TryCreateBounds(ModuleInfo? module, AobScanRange? range, int patternLength, + out AobScanBounds bounds, out CheatEngineFailure failure) { - if (TryGetCancellationFailure(cancellationToken, out failure)) + Address start = Address.Zero; + Address stop = new(ulong.MaxValue); + if (module is { } resolved) { - result = default; - return false; + if (!AobScanBounds.TryFromModule(in resolved, out AobScanBounds moduleBounds)) + { + bounds = default; + failure = ModuleFailure(CheatEngineFailureKind.InvalidHostResult, + "Cheat Engine reported a module that does not fit in the 64-bit address space."); + return false; + } + + start = moduleBounds.Start; + stop = moduleBounds.Stop; } - bool hasModuleRange = false; - ModuleRange moduleRange = default; - if (request.Module.HasValue && - !TryGetModuleRange(request.Module.Value, out hasModuleRange, out moduleRange, out failure)) + if (range is { } requested) { - result = default; - return false; + Address rangeStop = ToStop(requested.End, patternLength); + if (start < requested.Start) + { + start = requested.Start; + } + + if (rangeStop < stop) + { + stop = rangeStop; + } } - // Module resolution is deliberately completed before the unbounded CE AOB scan. The range still acts as a - // managed post-filter because the SDK AOB binding does not accept a module constraint. - if (TryGetCancellationFailure(cancellationToken, out failure)) + if (start < stop && stop.Value - start.Value >= PatternLength(patternLength) && + AobScanBounds.TryCreate(start, stop, out bounds)) { - result = default; - return false; + failure = default; + return true; } - AobScanHostStatus status = - _scanPort.TryScan(request.Pattern.Value, request.Options, out IAobMatchList? matchList); - if (TryGetCancellationFailure(cancellationToken, out failure)) + bounds = default; + failure = Rejected(module.HasValue + ? range.HasValue + ? "The AOB range leaves no room for a whole match inside the requested module." + : "The requested module is smaller than the AOB pattern." + : "The AOB range leaves no room for a match below the top of the 64-bit address space."); + return false; + } + + /// + /// The one scope rule of both routes: a module keeps a match only when all of its pattern bytes lie inside it, and a + /// range keeps a match whose start lies in [Start, End] and whose last byte lies below the top of the address + /// space (the bounded route cannot express a stop above it). + /// + /// A match start that Cheat Engine returned. + /// The validated request. + /// The resolved module, or . + /// when the match belongs to the request. + private static bool IsInsideRequest(Address address, AobScanRequest request, ModuleRange moduleRange) + { + ulong length = PatternLength(request.Pattern.ByteLength); + return moduleRange.ContainsMatch(address, length) && + (request.Range is not { } range || + (range.Contains(address) && address.Value <= ulong.MaxValue - length)); + } + + /// Returns the byte length of a match, at least one. + private static ulong PatternLength(int patternLength) + { + return (ulong) Math.Max(patternLength, 1); + } + + /// + /// Converts the inclusive end of a range of match starts into the exclusive stop Cheat Engine needs: a match + /// starting at ends before end + patternLength. + /// + /// The last allowed match start. + /// The number of byte positions of the pattern (at least one). + /// + /// end + patternLength, checked and saturated at the last address: at the top of the address space a match + /// whose last byte is the last address cannot be expressed and is not reported by the bounded route. + /// + internal static Address ToStop(Address end, int patternLength) + { + ulong length = PatternLength(patternLength); + return end.Value > ulong.MaxValue - length ? new Address(ulong.MaxValue) : new Address(end.Value + length); + } + + /// Returns how many addresses either route copies: the request limit, capped by the Client. + /// The requested materialization limit. + /// min(maximumResults, ScanResourceLimits.MaximumPatternMatches - 1). + /// + /// The same cap on both routes keeps one request's answer independent of the route: a result cut by the cap is + /// truncated on either route. + /// + internal static int GetMaterializationLimit(int maximumResults) + { + return Math.Min(maximumResults, ScanResourceLimits.MaximumPatternMatches - 1); + } + + /// Returns the bounded route's destination length: one more than the copy limit, to prove truncation. + /// The requested materialization limit. + /// min(maximumResults + 1, ScanResourceLimits.MaximumPatternMatches). + internal static int GetBoundedDestinationLength(int maximumResults) + { + return GetMaterializationLimit(maximumResults) + 1; + } + + /// Validates, dispatches, and classifies one scan identically for every public entry point. + private ScanOutcome Execute(AobScanRequest request, CancellationToken cancellationToken) + { + // Arguments first, then the activation: an ended or stopping activation throws before a refusal is reported. + ValidateRequest(request); + _dispatcher.Lifetime.ThrowIfDispatchRefused(ScanOperation); + ScanInput input = new(this, request, cancellationToken); + if (!_dispatcher.TryInvoke(input, static current => current.Scanner.ScanOnDispatchThread(current), + out ScanOutcome outcome, out CheatEngineFailure failure, cancellationToken)) { - matchList?.Dispose(); - result = default; - return false; + return ScanOutcome.Failed(failure, null); } - if (status == AobScanHostStatus.Rejected) + if (outcome.Metrics is { } metrics) { - result = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, - "Cheat Engine did not return an AOB result list."); - return false; + // Counts and durations only, after the dispatched callback returned (A24-17). + _dispatcher.Lifetime.Diagnostics.PatternScanCompleted(metrics.Scope, + (long) Math.Min(metrics.HostResultCount, long.MaxValue), + metrics.MaterializedCount, outcome.Succeeded && outcome.Result.IsTruncated, + (long) metrics.HostScanElapsed.TotalMilliseconds, (long) metrics.MaterializationElapsed.TotalMilliseconds); } - if (status != AobScanHostStatus.Success || matchList is null) + return outcome; + } + + private ScanOutcome ScanOnDispatchThread(ScanInput input) + { + AobScanRequest request = input.Request; + CancellationToken cancellationToken = input.CancellationToken; + if (cancellationToken.IsCancellationRequested) { - result = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, - "Cheat Engine returned an invalid AOB result list."); - return false; + return ScanOutcome.Failed(CancelledBeforeScan(), null); } - using (matchList) + if (!request.Module.HasValue && !request.Range.HasValue) { - if (TryGetCancellationFailure(cancellationToken, out failure)) + ScanOutcome global = ScanGlobal(request, ModuleRange.None, PatternScanScope.GlobalHostScan, + cancellationToken); + return global with { - result = default; - return false; - } + RouteReason = PatternScanRouteReason.UnscopedRequest + }; + } - if (!matchList.TryGetCount(out int count) || count < 0) + ModuleInfo? module = null; + if (request.Module.HasValue) + { + if (!TryGetModule(request.Module.Value, out ModuleInfo resolved, out CheatEngineFailure moduleFailure)) { - result = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, - "Cheat Engine returned an invalid AOB result count."); - return false; + return ScanOutcome.Failed(moduleFailure, null); } - return TryMaterializeMatches(matchList, count, request, hasModuleRange, moduleRange, cancellationToken, - out result, out failure); + module = resolved; + } + + if (!TryCreateBounds(module, request.Range, request.Pattern.ByteLength, out AobScanBounds bounds, + out CheatEngineFailure boundsFailure)) + { + return ScanOutcome.Failed(boundsFailure, null); + } + + // Module resolution is deliberately completed before any scan, whichever route runs. + if (cancellationToken.IsCancellationRequested) + { + return ScanOutcome.Failed(CancelledBeforeScan(), null); + } + + TargetSelectionFacts selection; + try + { + selection = _scanPort.ObserveSelection(); } + catch (Exception observationFault) when (SdkBoundary.IsSdkFault(observationFault)) + { + return ScanOutcome.Failed(SdkBoundary.Translate(ScanOperation, observationFault, + CheatEngineHostEffect.NotStarted, _dispatcher.Lifetime), null); + } + + if (cancellationToken.IsCancellationRequested) + { + return ScanOutcome.Failed(CancelledBeforeScan(), null); + } + + ModuleRange moduleRange = module is { ImageSize: { } size } found + ? new ModuleRange(found.BaseAddress.Value, size.Value) + : ModuleRange.None; + return selection.IsQualified + ? ScanWithinBounds(request, bounds, moduleRange, cancellationToken) + : FallBack(request, moduleRange, cancellationToken); } - private static bool TryMaterializeMatches( - IAobMatchList matches, - int count, - AobScanRequest request, - bool hasModuleRange, - ModuleRange moduleRange, - CancellationToken cancellationToken, - out AobScanResult result, - out CheatEngineFailure failure) + /// Runs the global route for a module or range request whose bounded route cannot run. + /// + /// The route reason says that the bounded route could not qualify the target, so the result is never reported as + /// verified, even when the global scan's own before and after observations named one qualified incarnation (for + /// example after Cheat Engine returned no MemScan on a qualified target): the two public values never contradict + /// each other, and a consumer that gates on either one reaches the same conclusion. + /// + private ScanOutcome FallBack(AobScanRequest request, ModuleRange moduleRange, CancellationToken cancellationToken) { - // Do not preallocate to a caller-controlled materialization limit. The limit remains strict below, while - // storage grows only for addresses that survived every managed filter. - ImmutableArray
.Builder materialized = ImmutableArray.CreateBuilder
(); - for (int index = 0; index < count; index++) + ScanOutcome global = + ScanGlobal(request, moduleRange, PatternScanScope.GlobalHostScanWithManagedFilter, cancellationToken); + return global with + { + RouteReason = PatternScanRouteReason.TargetIdentityNotQualified, + TargetIdentityVerified = false + }; + } + + /// Runs the bounded route and falls back to the global route when the SDK says it cannot run. + private ScanOutcome ScanWithinBounds(AobScanRequest request, AobScanBounds bounds, ModuleRange moduleRange, + CancellationToken cancellationToken) + { + Address[] destination = new Address[GetBoundedDestinationLength(request.MaximumResults)]; + AobBoundedHostResult bounded; + try + { + bounded = _scanPort.TryScanWithinBounds(request.Pattern.Value, bounds, + AobScanMapping.ToSdkOptions(request.Protection, request.Alignment), destination, cancellationToken); + } + catch (Exception scanFault) when (SdkBoundary.IsSdkFault(scanFault)) { - if (TryGetCancellationFailure(cancellationToken, out failure)) + // The SDK releases its session on every exit before a fault propagates; the scan may or may not have run. + CheatEngineFailure translated = + SdkBoundary.Translate(ScanOperation, scanFault, CheatEngineHostEffect.Unknown, _dispatcher.Lifetime); + return ScanOutcome.Failed(translated, null) with { - result = default; - return false; - } + RouteReason = PatternScanRouteReason.ScopedRequestOnQualifiedTarget + }; + } - if (!TryGetMatchAddress(matches, index, out Address address, out failure)) + AobBoundedDisposition disposition = + AobScanMapping.ClassifyBounded(ScanOperation, bounded, out CheatEngineFailure failure); + bool released = AobScanMapping.IsSessionReleaseConfirmed(bounded, out LeaseReleaseKind releaseKind); + if (disposition == AobBoundedDisposition.FallBack && released) + { + if (cancellationToken.IsCancellationRequested) { - result = default; - return false; + // No global scan ran: the outcome reports the bounded attempt, the only route that did. + CheatEngineFailure cancelled = + bounded.HostScanElapsed > TimeSpan.Zero ? CancelledAfterScan() : CancelledBeforeScan(); + return ScanOutcome.Failed(cancelled, null) with + { + HostOutcome = AobScanMapping.ToHostOutcome(bounded.Kind), + RouteReason = PatternScanRouteReason.ScopedRequestOnQualifiedTarget + }; } - if (TryGetCancellationFailure(cancellationToken, out failure)) + return WithPriorHostScan(FallBack(request, moduleRange, cancellationToken), bounded.HostScanElapsed); + } + + // A failure after the SDK read the host count keeps the metrics of the work that happened. + PatternScanMetrics? failureMetrics = BoundedFailureMetrics(bounded); + ScanOutcome outcome = disposition == AobBoundedDisposition.Publish + ? Publish(bounded, destination, request, moduleRange, cancellationToken) + : ScanOutcome.Failed(failure, failureMetrics); + ScanOutcome final = released + ? outcome + : ScanOutcome.Failed(CreateReleaseFailure(outcome.Succeeded ? null : outcome.Failure, SessionSubject, + releaseKind, null), outcome.Metrics); + + // The SDK checks the session's target incarnation throughout: a published result is attributed to it. + return final with + { + HostOutcome = AobScanMapping.ToHostOutcome(bounded.Kind), + RouteReason = PatternScanRouteReason.ScopedRequestOnQualifiedTarget, + TargetIdentityVerified = final.Succeeded + }; + } + + /// Publishes the in-bounds addresses the SDK copied, after the Client's own scope check. + /// + /// The SDK already dropped every address below the start or at or after the stop, but it tests the match start + /// only: a match that straddles the module end is excluded only because Cheat Engine honours its stop bound (a + /// host observation). , the rule of the global route too, makes that exclusion a + /// Client guarantee; an address it drops is counted as filtered out. The destination holds one more address than + /// the limit, so a full destination proves truncation. + /// + private static ScanOutcome Publish(AobBoundedHostResult bounded, Address[] destination, AobScanRequest request, + ModuleRange moduleRange, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + // The SDK had read the count and copied the rows: the metrics describe that work, nothing is published. + return ScanOutcome.Failed(CancelledAfterScan(), BoundedFailureMetrics(bounded)); + } + + if ((uint) bounded.Written > (uint) destination.Length) + { + return ScanOutcome.Failed(new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + "The bounded AOB scan reported more addresses than its destination holds.", null, + CheatEngineHostEffect.Completed), null); + } + + long filterStarted = Stopwatch.GetTimestamp(); + int limit = destination.Length - 1; + ImmutableArray
.Builder copied = ImmutableArray.CreateBuilder
(Math.Min(bounded.Written, limit)); + int survivors = 0; + int dropped = 0; + for (int index = 0; index < bounded.Written; index++) + { + Address address = destination[index]; + if (!IsInsideRequest(address, request, moduleRange)) { - result = default; - return false; + dropped++; + continue; } - if (!IsIncluded(address, request, hasModuleRange, moduleRange)) + survivors++; + if (copied.Count < limit) { - continue; + copied.Add(address); } + } + + TimeSpan materializationElapsed = bounded.CopyElapsed + Stopwatch.GetElapsedTime(filterStarted); + if (BoundedMetrics(bounded, dropped, copied.Count, materializationElapsed, true) is not { } metrics) + { + return ScanOutcome.Failed(new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + "The bounded AOB scan reported inconsistent counts.", null, CheatEngineHostEffect.Completed), null); + } - if (materialized.Count == request.MaximumResults) + bool truncated = survivors > limit; + if (!truncated && bounded.IsMaterializationLimitReached && dropped > 0) + { + // A full destination whose addresses the Client dropped leaves unread rows unknown. + if (survivors == 0) { - result = new AobScanResult(materialized.ToImmutable(), true); - failure = default; - return true; + return ScanOutcome.Failed(new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, + ScanOperation, "The bounded AOB scan filled its destination with addresses outside the request, " + + "so whether an in-range match exists is unknown.", null, + CheatEngineHostEffect.Completed), metrics); } - materialized.Add(address); + truncated = true; } - if (TryGetCancellationFailure(cancellationToken, out failure)) + return ScanOutcome.Success(new AobScanResult(copied.ToImmutable(), truncated)) with { - result = default; - return false; + Metrics = metrics + }; + } + + /// + /// Returns the metrics of a bounded scan that published nothing, when the SDK read its host count; otherwise, or + /// when its counts contradict each other, . + /// + private static PatternScanMetrics? BoundedFailureMetrics(AobBoundedHostResult bounded) + { + return AobScanMapping.HasReadCount(bounded) ? BoundedMetrics(bounded, 0, 0, bounded.CopyElapsed, false) : null; + } + + /// + /// Adds the Cheat Engine time of a bounded scan that ran before its fallback to the fallback's metrics: the + /// request cost both scans. + /// + private static ScanOutcome WithPriorHostScan(ScanOutcome fallback, TimeSpan priorHostScan) + { + if (priorHostScan <= TimeSpan.Zero || fallback.Metrics is not { } metrics) + { + return fallback; } - result = new AobScanResult(materialized.ToImmutable(), false); - failure = default; - return true; + return fallback with + { + Metrics = new PatternScanMetrics(metrics.Scope, metrics.HostResultCount, metrics.ExaminedCount, + metrics.FilteredOutCount, metrics.MaterializedCount, metrics.BelowStartSkippedCount, + metrics.AtOrAfterStopSkippedCount, metrics.UnreadHostRowCount, metrics.InBoundsCountIsExact, + metrics.HostScanElapsed + priorHostScan, metrics.MaterializationElapsed) + }; } - private static bool TryGetMatchAddress(IAobMatchList matches, int index, out Address address, - out CheatEngineFailure failure) + /// Builds the metrics of a bounded scan, or when its counts contradict each other. + /// + /// The SDK's skipped rows and the rows the Client's own checks dropped are the filtered-out rows. The in-request + /// count is exact only for a published result whose rows were all read. + /// + private static PatternScanMetrics? BoundedMetrics(AobBoundedHostResult bounded, int dropped, int materialized, + TimeSpan materializationElapsed, bool published) { - if (matches.TryGetItem(index, out string? text) && Address.TryParse(text, out address)) + ulong skipped = bounded.BelowStartSkipped + bounded.AtOrAfterStopSkipped; + ulong filtered = skipped + (ulong) dropped; + if (skipped < bounded.BelowStartSkipped || filtered < skipped || bounded.RowsRead > bounded.HostResultCount || + bounded.UnreadHostRows != bounded.HostResultCount - bounded.RowsRead || filtered > bounded.RowsRead || + (ulong) materialized > bounded.RowsRead - filtered || bounded.HostScanElapsed < TimeSpan.Zero || + materializationElapsed < TimeSpan.Zero) { - failure = default; - return true; + return null; } - address = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, - $"AOB result {index} was not a hexadecimal address."); - return false; + return new PatternScanMetrics(PatternScanScope.HostBoundedRange, bounded.HostResultCount, bounded.RowsRead, + filtered, materialized, bounded.BelowStartSkipped, bounded.AtOrAfterStopSkipped, bounded.UnreadHostRows, + published && bounded.UnreadHostRows == 0, bounded.HostScanElapsed, materializationElapsed); } - private static bool TryGetCancellationFailure(CancellationToken cancellationToken, out CheatEngineFailure failure) + /// Runs one global AOBScan and copies its post-filtered addresses. + private ScanOutcome ScanGlobal(AobScanRequest request, ModuleRange moduleRange, PatternScanScope scope, + CancellationToken cancellationToken) { - if (!cancellationToken.IsCancellationRequested) + long hostScanStarted = Stopwatch.GetTimestamp(); + AobHostOutcome host; + IAobMatchList? matchList; + try { - failure = default; - return false; + host = _scanPort.TryScan(request.Pattern.Value, + AobScanMapping.ToSdkOptions(request.Protection, request.Alignment), out matchList); + } + catch (OwnershipHandoffException handoff) + { + // CE's scan returned a list that the port could not publish, and the list's release was not confirmed: the + // publication fault keeps its classification, and the unconfirmed release makes it CleanupUnconfirmed. + return ScanOutcome.Failed( + OwnershipHandoff.ToFailure(ScanOperation, handoff, ListSubject, _dispatcher.Lifetime), null); + } + catch (Exception scanFault) when (SdkBoundary.IsSdkFault(scanFault)) + { + // The SDK call may or may not have run CE's scan. OwnershipHandoff already released a list that was acquired + // before the fault, so nothing is left for this method to release. + return ScanOutcome.Failed( + SdkBoundary.Translate(ScanOperation, scanFault, CheatEngineHostEffect.Unknown, _dispatcher.Lifetime), + null); + } + + TimeSpan hostScanElapsed = Stopwatch.GetElapsedTime(hostScanStarted); + if (matchList is null) + { + return Attribute(ScanOutcome.Failed(ClassifyWithoutList(host), null), host); } - failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, _scanOperation, - "The AOB scan was cancelled."); - return true; + // From here on this method is the single release authority for the owned list: every path below releases it + // exactly once, on this dispatched callback, and a release that is not confirmed is never hidden behind a success. + ScanOutcome outcome; + try + { + outcome = Consume(matchList, host, request, new GlobalCopy(moduleRange, scope, hostScanElapsed), + cancellationToken); + } + catch (Exception copyFault) when (SdkBoundary.IsSdkFault(copyFault)) + { + // A fault while reading the SDK-owned list: CE's scan had returned, so no scan work is outstanding. The fault + // is classified like every other SDK fault, including the external-reset rule. + outcome = ScanOutcome.Failed( + SdkBoundary.Classify(ScanOperation, copyFault, CheatEngineHostEffect.Completed), null); + ScanOutcome released = Release(matchList, outcome); + SdkBoundary.ThrowIfActivationEnded(ScanOperation, copyFault, _dispatcher.Lifetime); + return Attribute(released, host); + } + catch (Exception) + { + // A lifecycle fault propagates unchanged; the list is still released once, on this callback. + _ = ReleaseList(matchList, out _); + throw; + } + + return Attribute(Release(matchList, outcome), host); } - private static bool IsIncluded(Address address, AobScanRequest request, bool hasModuleRange, - ModuleRange moduleRange) + /// + /// Records the host's own outcome of a global scan, and whether its addresses are attributed to one qualified + /// incarnation: only a success whose selection was the same qualified incarnation before and after the call. + /// + private static ScanOutcome Attribute(ScanOutcome outcome, AobHostOutcome host) { - return (!hasModuleRange || moduleRange.Contains(address)) && - (!request.Range.HasValue || request.Range.Value.Contains(address)); + return outcome with + { + HostOutcome = AobScanMapping.ToHostOutcome(host.Kind), + TargetIdentityVerified = outcome.Succeeded && host.IsSameQualifiedIncarnation + }; } - internal static bool TryValidateRequest(AobScanRequest request, out CheatEngineFailure failure) + /// Classifies an outcome that handed out no result list, by SDK outcome only (never by error text). + /// + /// NoResult is explicitly indeterminate (): on the pinned + /// profile Cheat Engine returns nil for zero matches, and a host failure can produce the same shape. It is + /// never reported as , and it is attributed to the target only when + /// the selection did not change during the call. + /// + private static CheatEngineFailure ClassifyWithoutList(AobHostOutcome host) { - if (string.IsNullOrWhiteSpace(request.Pattern.Value)) + if (host.Kind == AobScanOutcomeKind.NoResult && + AobScanMapping.TryGetTargetFailure(ScanOperation, + AobScanMapping.JudgeTarget(host.TargetBefore, host.TargetAfter), out CheatEngineFailure targetFailure)) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, - "An AOB scan requires a normalized, non-empty pattern."); - return false; + return targetFailure; } - if (request.MaximumResults <= 0) + return AobScanMapping.ToFailure(ScanOperation, host); + } + + private static ScanOutcome Consume(IAobMatchList matchList, AobHostOutcome host, AobScanRequest request, + GlobalCopy copy, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, - "An AOB scan requires a positive materialization limit."); - return false; + return ScanOutcome.Failed(CancelledAfterScan(), null); } - if (request.Module.HasValue && string.IsNullOrWhiteSpace(request.Module.Value.Value)) + if (host.Kind is not (AobScanOutcomeKind.Matches or AobScanOutcomeKind.NoMatches)) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, - "An AOB module filter must be non-empty."); - return false; + // The SDK hands out a list only with a verified count; any other outcome with a list is a broken contract. + return ScanOutcome.Failed(InvalidList(), null); } - if (request.Range.HasValue && request.Range.Value.End < request.Range.Value.Start) + if (AobScanMapping.TryGetTargetFailure(ScanOperation, + AobScanMapping.JudgeTarget(host.TargetBefore, host.TargetAfter), out CheatEngineFailure targetFailure)) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, - "An AOB range end address must not precede its start address."); - return false; + return ScanOutcome.Failed(targetFailure, null); + } + + long materializationStarted = Stopwatch.GetTimestamp(); + if (!matchList.TryGetCount(out int count) || count < 0) + { + return ScanOutcome.Failed(new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + "Cheat Engine returned an invalid AOB result count.", null, CheatEngineHostEffect.Completed), null); + } + + MaterializationProgress progress = new(count); + ScanOutcome outcome = Materialize(matchList, request, copy.ModuleRange, cancellationToken, ref progress); + PatternScanMetrics metrics = progress.ToMetrics(copy.Scope, copy.HostScanElapsed, + Stopwatch.GetElapsedTime(materializationStarted)); + return outcome with + { + Metrics = metrics + }; + } + + private static ScanOutcome Materialize(IAobMatchList matches, AobScanRequest request, ModuleRange moduleRange, + CancellationToken cancellationToken, ref MaterializationProgress progress) + { + // Do not preallocate to a caller-controlled materialization limit. The limit remains strict below, while + // storage grows only for addresses that survived every managed filter. + int limit = GetMaterializationLimit(request.MaximumResults); + ImmutableArray
.Builder materialized = ImmutableArray.CreateBuilder
(); + for (int index = 0; index < progress.HostResultCount; index++) + { + if (cancellationToken.IsCancellationRequested) + { + return ScanOutcome.Failed(CancelledAfterScan(), null); + } + + if (!matches.TryGetItem(index, out string? text) || !Address.TryParse(text, out Address address)) + { + return ScanOutcome.Failed(new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, + ScanOperation, $"AOB result {index} was not a hexadecimal address.", null, + CheatEngineHostEffect.Completed), null); + } + + progress.Examined++; + if (!IsInsideRequest(address, request, moduleRange)) + { + progress.FilteredOut++; + continue; + } + + if (materialized.Count == limit) + { + // One more post-filtered address proves that the copy is incomplete. It is examined but not copied. + return ScanOutcome.Success(new AobScanResult(materialized.ToImmutable(), true)); + } + + materialized.Add(address); + progress.Materialized = materialized.Count; + } + + if (cancellationToken.IsCancellationRequested) + { + return ScanOutcome.Failed(CancelledAfterScan(), null); + } + + return ScanOutcome.Success(new AobScanResult(materialized.ToImmutable(), false)); + } + + /// Releases the owned list once and turns an unconfirmed release into the operation's failure. + /// + /// Only confirms the release (). Any + /// other status is never reported as success, even when every address was copied: the copied result is discarded + /// (audit ch.24 cleanup row, ADR-08). When the operation had already failed, the unconfirmed release is added to + /// the original failure instead of replacing its cause. The metrics are kept: they describe the work that + /// happened. + /// + private static ScanOutcome Release(IAobMatchList matchList, ScanOutcome outcome) + { + LeaseReleaseKind released = ReleaseList(matchList, out Exception? releaseFault); + return released == LeaseReleaseKind.Released + ? outcome + : ScanOutcome.Failed( + CreateReleaseFailure(outcome.Succeeded ? null : outcome.Failure, ListSubject, released, releaseFault), + outcome.Metrics); + } + + /// Releases the list once and maps the SDK status to the Client lease vocabulary. + /// + /// The SDK release never throws. An SDK fault from a port that breaks that contract is kept as an unconfirmed + /// release, so it never crosses a Try method. + /// + private static LeaseReleaseKind ReleaseList(IAobMatchList matchList, out Exception? releaseFault) + { + try + { + releaseFault = null; + return SdkReleaseOutcomes.FromTarget(matchList.Release()).Kind; + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + releaseFault = fault; + return LeaseReleaseKind.Unknown; + } + } + + private static CheatEngineFailure CreateReleaseFailure(CheatEngineFailure? primaryFailure, string subject, + LeaseReleaseKind released, Exception? releaseFault) + { + if (primaryFailure is not { } primary) + { + // An unconfirmed host release is an indeterminate host result, never a Client state. + return new CheatEngineFailure(CheatEngineFailureKind.IndeterminateHostResult, ScanOperation, + $"The {subject} release was not confirmed ({released}); copied results were discarded.", + releaseFault, CheatEngineHostEffect.CleanupUnconfirmed); } - failure = default; - return true; + return OwnershipHandoff.WithUnconfirmedRelease(primary, subject, released, releaseFault); } - private bool TryGetModuleRange(ModuleName requested, out bool hasRange, out ModuleRange range, - out CheatEngineFailure failure) + private static CheatEngineFailure Rejected(string message) { - ModuleInfo[] modules = new ModuleInfo[_maximumModuleSnapshot]; - hasRange = false; - range = default; - InspectionStatus status = _scanPort.EnumerateModules(modules, out int written); + return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, message, null, + CheatEngineHostEffect.NotStarted); + } + + private static CheatEngineFailure InvalidList() + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + AobScanMapping.InvalidListMessage, null, CheatEngineHostEffect.Completed); + } + + private static CheatEngineFailure CancelledBeforeScan() + { + return CancellationMapping.BeforeNativeCall(ScanOperation, + "The AOB scan was cancelled before Cheat Engine started it."); + } + + private static CheatEngineFailure CancelledAfterScan() + { + return CancellationMapping.AfterNativeCall(ScanOperation, + "The AOB scan was cancelled after Cheat Engine completed it; no copied result was published."); + } + + private bool TryGetModule(ModuleName requested, out ModuleInfo module, out CheatEngineFailure failure) + { + ModuleInfo[] modules = new ModuleInfo[MaximumModuleSnapshot]; + module = default; + InspectionStatus status; + int written; + try + { + status = _scanPort.EnumerateModules(modules, out written); + } + catch (Exception inspectionFault) when (SdkBoundary.IsSdkFault(inspectionFault)) + { + // Module inspection failed before the AOB scan was started. + failure = SdkBoundary.Translate(ScanOperation, inspectionFault, CheatEngineHostEffect.NotStarted, + _dispatcher.Lifetime); + return false; + } + if (status != InspectionStatus.Success) { - failure = new CheatEngineFailure( + failure = ModuleFailure( status == InspectionStatus.DestinationTooSmall ? CheatEngineFailureKind.ResultLimitExceeded : CheatEngineFailureKind.CapabilityUnavailable, - _inModuleOperation, $"Module inspection returned '{status}'."); + $"Module inspection returned '{status}'."); return false; } if ((uint) written > modules.Length) { - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _inModuleOperation, + failure = ModuleFailure(CheatEngineFailureKind.InvalidHostResult, "Cheat Engine returned an invalid module count."); return false; } @@ -290,54 +848,135 @@ private bool TryGetModuleRange(ModuleName requested, out bool hasRange, out Modu bool found = false; for (int index = 0; index < written; index++) { - ModuleInfo module = modules[index]; - if (!string.Equals(module.Name, requested.Value, StringComparison.OrdinalIgnoreCase)) + ModuleInfo candidate = modules[index]; + if (!string.Equals(candidate.Name, requested.Value, StringComparison.OrdinalIgnoreCase)) { continue; } if (found) { - failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, _inModuleOperation, + failure = ModuleFailure(CheatEngineFailureKind.AmbiguousMatch, $"More than one module named '{requested.Value}' was present in the selected target."); return false; } - if (!module.ImageSize.HasValue) + if (!candidate.ImageSize.HasValue) { - failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, - _inModuleOperation, "Cheat Engine did not report the requested module's image size."); + failure = ModuleFailure(CheatEngineFailureKind.CapabilityUnavailable, + "Cheat Engine did not report the requested module's image size."); return false; } - if (module.ImageSize.Value.Value == 0) + if (candidate.ImageSize.Value.Value == 0) { - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _inModuleOperation, + failure = ModuleFailure(CheatEngineFailureKind.InvalidHostResult, "Cheat Engine reported a zero-length requested module."); return false; } found = true; - range = new ModuleRange(module.BaseAddress.Value, module.ImageSize.Value.Value); + module = candidate; } if (found) { - hasRange = true; failure = default; return true; } - failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, _inModuleOperation, + failure = ModuleFailure(CheatEngineFailureKind.NotFound, $"Module '{requested.Value}' was not present in the selected target."); return false; } - private readonly record struct ModuleRange(ulong Start, ulong Size) + /// A module-resolution failure: the AOB scan itself never started. + private static CheatEngineFailure ModuleFailure(CheatEngineFailureKind kind, string message) { - internal bool Contains(Address address) + return new CheatEngineFailure(kind, ScanOperation, message, null, CheatEngineHostEffect.NotStarted); + } + + /// What the global route's copy needs besides the list: its filter, its scope and the host scan time. + private readonly record struct GlobalCopy(ModuleRange ModuleRange, PatternScanScope Scope, TimeSpan HostScanElapsed); + + private readonly record struct ScanInput( + PatternScanner Scanner, + AobScanRequest Request, + CancellationToken CancellationToken); + + /// The single internal classification shared by and . + private readonly record struct ScanOutcome( + bool Succeeded, + AobScanResult Result, + CheatEngineFailure Failure, + PatternScanMetrics? Metrics) + { + /// Gets what the host reported for the scan that ran; unknown when none ran. + internal PatternScanHostOutcomeKind HostOutcome + { + get; + init; + } + + /// Gets why the scan ran on its route; unknown when none ran. + internal PatternScanRouteReason RouteReason + { + get; + init; + } + + /// Gets whether a published result is attributed to one qualified target incarnation. + internal bool TargetIdentityVerified + { + get; + init; + } + + internal static ScanOutcome Success(AobScanResult result) + { + return new ScanOutcome(true, result, default, null); + } + + internal static ScanOutcome Failed(CheatEngineFailure failure, PatternScanMetrics? metrics) + { + return new ScanOutcome(false, default, failure, metrics); + } + } + + /// Allocation-free counters of the copy loop. + private struct MaterializationProgress(int hostResultCount) + { + internal readonly int HostResultCount = hostResultCount; + internal int Examined; + internal int FilteredOut; + internal int Materialized; + + internal readonly PatternScanMetrics ToMetrics(PatternScanScope scope, TimeSpan hostScanElapsed, + TimeSpan materializationElapsed) + { + // The global route reads every row it examines; the in-request count is exact once all rows were read. + return new PatternScanMetrics(scope, (ulong) HostResultCount, (ulong) Examined, (ulong) FilteredOut, + Materialized, 0, 0, (ulong) (HostResultCount - Examined), Examined == HostResultCount, hostScanElapsed, + materializationElapsed); + } + } + + private readonly record struct ModuleRange(bool IsActive, ulong Start, ulong Size) + { + internal static ModuleRange None => default; + + internal ModuleRange(ulong start, ulong size) + : this(true, start, size) + { + } + + /// Gets whether a match of bytes starting at the address lies entirely inside. + /// The match start. + /// The match length in bytes, at least one. + /// without a module, or when [address, address + length) is inside it. + internal bool ContainsMatch(Address address, ulong length) { - return address.Value >= Start && address.Value - Start < Size; + return !IsActive || (length <= Size && address.Value >= Start && address.Value - Start <= Size - length); } } } diff --git a/libs/CheatEngine.Client.Core/Domains/PointerWidthPolicy.cs b/libs/CheatEngine.Client.Core/Domains/PointerWidthPolicy.cs new file mode 100644 index 0000000..abe6dee --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/PointerWidthPolicy.cs @@ -0,0 +1,115 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// The Client's policy for its own pointer-typed operations: an unknown target bitness, or a Cheat Engine configured +/// pointer size that differs from it, refuses the operation (audit A10-18, A12-24, AUD-07, CLI-MEM-1; spike C3 D3). +/// +/// +/// +/// Cheat Engine's readPointer follows the target bitness (targetIs64Bit), not the configured pointer +/// size (spike C3 D3(d), a Lua-only host observation). The Client therefore passes the observed bitness to the +/// width-qualified CheatEngine.SDK pointer overloads (TargetMemory.TryReadPointer and +/// TryWritePointer with a ) on every pointer-typed path. Before any memory access +/// it refuses the operation when the bitness is unknown, and when CheatEngine.SDK reports that the configured +/// size differs from it (TargetArchitectureObservation.ConfiguredPointerSizeDiffersFromBitness) instead +/// of choosing a width silently. A configured size that could not be observed is no evidence of a mismatch: the +/// operation proceeds with the bitness. No width is ever taken from the plugin's own process (CESDK1020). +/// +/// +/// What the configured size affects besides the value Cheat Engine reports (pointer scanner, address parsing, +/// display) is not established; custom codecs receive the facts through +/// and +/// and decide for themselves. +/// +/// +internal static class PointerWidthPolicy +{ + /// Gets whether a pointer-typed operation may proceed with the observed facts. + /// The target facts observed once for the operation. + /// + /// when the bitness is known and no observed configured pointer size differs from it. + /// + internal static bool IsAdmitted(ObservedTarget facts) + { + return facts.Bitness.IsKnown && !facts.ConfiguredPointerSizeDiffersFromBitness; + } + + /// Creates the refusal of a pointer-typed operation that did not admit. + /// The public Client operation name. + /// The refused facts. + /// The unknown-width refusal, or the mismatch refusal; both are NotStarted. + internal static CheatEngineFailure CreateRefusal(string operation, ObservedTarget facts) + { + return facts.Bitness.IsKnown + ? CreateMismatchFailure(operation, facts) + : CreateUnknownWidthFailure(operation, facts); + } + + /// Gets whether an address fits the observed pointer width of the target. + /// The address the Client is about to access. + /// The observed target bitness. + /// only for an address above 4 GiB on a 32-bit target. + internal static bool Fits(Address address, PointerSize width) + { + return width != PointerSize.Bit32 || address.Value <= uint.MaxValue; + } + + /// + /// Returns the kind of an unknown-width refusal. ADR-08: only an observed "no process selected" is + /// TargetNotAttached; every other status the SDK reported (a target change, a file opened as a process, an + /// absent, raising or malformed global) keeps its own kind, and a selected target without a bitness is + /// InvalidState, like the SDK's own PointerWidthUnknown. + /// + internal static CheatEngineFailureKind GetUnknownWidthKind(ObservedTarget facts) + { + return facts.HasTarget + ? CheatEngineFailureKind.InvalidState + : RuntimeObservationMapping.ToFailureKind(facts.Status.Kind); + } + + /// Creates the stable message of an unknown-width refusal; it names the SDK status only. + internal static string CreateUnknownWidthMessage(ObservedTarget facts) + { + return facts.Status.Kind switch + { + ProcessOperationStatusKind.Success => + "Cheat Engine did not report the process width of the selected target.", + ProcessOperationStatusKind.TargetNotAttached => + "No target process is selected, so there is no process width.", + ProcessOperationStatusKind.TargetChanged => + "The selected target changed while the Client observed its process width.", + ProcessOperationStatusKind.FileAsProcessTarget => + "The selected target is a file opened as a process, so the process width is unobservable.", + _ => $"Cheat Engine reported {facts.Status.Kind} for the selected target, so the process width is " + + "unobservable." + }; + } + + /// Creates the refusal of a pointer-typed operation whose target bitness is unknown. + internal static CheatEngineFailure CreateUnknownWidthFailure(string operation, ObservedTarget facts) + { + return new CheatEngineFailure(GetUnknownWidthKind(facts), operation, CreateUnknownWidthMessage(facts), null, + CheatEngineHostEffect.NotStarted); + } + + /// Creates the refusal of a pointer-typed operation on a configured size that differs from the bitness. + internal static CheatEngineFailure CreateMismatchFailure(string operation, ObservedTarget facts) + { + return new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + CreateMismatchMessage(facts), null, CheatEngineHostEffect.NotStarted); + } + + /// Creates the stable message of a configured/bitness mismatch; it names widths only, never addresses. + internal static string CreateMismatchMessage(ObservedTarget facts) + { + return $"Cheat Engine's configured pointer size ({facts.ConfiguredPointerSizeBytes} bytes) differs from the " + + $"target process width ({facts.Bitness.Bytes} bytes). Cheat Engine's readPointer follows the " + + "process width, so the Client refuses pointer-typed operations instead of choosing a width. Restore the " + + "configured size or read explicit 32/64-bit integers."; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs b/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs deleted file mode 100644 index cc4704c..0000000 --- a/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs +++ /dev/null @@ -1,56 +0,0 @@ -using CheatEngine.Client.Runtime; -using CheatEngine.SDK.Engine.Runtime; - -namespace CheatEngine.Client.Core.Domains; - -internal readonly record struct ProbeResult(ClientCapabilityEvidenceGate Evidence, T? Value) -{ - internal bool HasValue => Evidence.State == ClientCapabilityEvidenceState.Satisfied; - - internal static ProbeResult Available(T value) - { - return new ProbeResult(new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied, - "The runtime probe returned a value in its documented shape."), value); - } - - internal static ProbeResult MissingGlobal() - { - return new ProbeResult(new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Missing, - "The required Cheat Engine runtime global is not available in this host."), default); - } - - internal static ProbeResult MissingCapability() - { - return new ProbeResult(new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Missing, - "The required Cheat Engine runtime capability is not supported by this host."), default); - } - - internal static ProbeResult Unknown(string reason) - { - return new ProbeResult(new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, reason), - default); - } - - internal static ProbeResult Faulted(string reason) - { - return new ProbeResult(new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Faulted, reason), - default); - } - - internal static ProbeResult Malformed(string reason) - { - return new ProbeResult(new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Malformed, reason), - default); - } - - internal RuntimeCapabilityAvailability ToAvailability(RuntimeCapabilityId capability) - { - RuntimeCapabilityAvailabilityState state = Evidence.State switch - { - ClientCapabilityEvidenceState.Satisfied => RuntimeCapabilityAvailabilityState.Available, - ClientCapabilityEvidenceState.Missing => RuntimeCapabilityAvailabilityState.Unavailable, - _ => RuntimeCapabilityAvailabilityState.Unknown - }; - return new RuntimeCapabilityAvailability(capability, state, RuntimeCapabilityContract.Unknown); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs index ab9a794..632b43d 100644 --- a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs @@ -6,65 +6,92 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; namespace CheatEngine.Client.Core.Domains; /// Owns deterministic target selection without taking ownership of Cheat Engine's global process state. -internal sealed class ProcessClient : IProcessClient +/// +/// +/// Every observation goes through ; the one call that changes Cheat Engine's +/// selection is , reached only from . +/// +/// +/// The selection identity is the PID and, for a local process, its incarnation: the PID and creation time that +/// CheatEngine.SDK's TargetSelection observed. A known incarnation is checked with +/// TargetSelection.ValidateCurrent, so the selection epoch advances when the same PID denotes another +/// process. Like the SDK, the Client cannot see a selection that changed and changed back between two +/// observations (A-B-A); only a later observation of another PID or incarnation advances the epoch. A CEServer or +/// unknown backend has no local incarnation and no local metadata. +/// +/// +internal sealed class ProcessClient : IProcessClient, ITargetSelectionBinder { private readonly Action? _admitStatefulOperation; private readonly ICheatEngineDispatcher _dispatcher; private readonly IProcessHost _host; + private readonly CoreLifetime? _lifetime; + private readonly IRuntimeObservationPort _observations; + private readonly IProcessSelectionPort _selection; private readonly Lock _selectionGate = new(); private readonly TargetSelectionLifetime _selectionLifetime; private ProcessSelection? _lastSelection; internal ProcessClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime) - : this(dispatcher, new LocalProcessHost(), - lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime)), lifetime.ThrowIfInactive) + : this(dispatcher, new LocalProcessHost(), SdkRuntimeObservationPort.Instance, SdkProcessSelectionPort.Instance, + lifetime) { } - internal ProcessClient(ICheatEngineDispatcher dispatcher, IProcessHost host, CoreLifetime lifetime) - : this(dispatcher, host, lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime)), - lifetime.ThrowIfInactive) + internal ProcessClient(ICheatEngineDispatcher dispatcher, IProcessHost host, + IRuntimeObservationPort observations, IProcessSelectionPort selection, CoreLifetime lifetime) + : this(dispatcher, host, observations, selection, + lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime)), lifetime.ThrowIfInactive, + lifetime) { } internal ProcessClient( ICheatEngineDispatcher dispatcher, IProcessHost host, + IRuntimeObservationPort observations, + IProcessSelectionPort selection, TargetSelectionLifetime selectionLifetime) - : this(dispatcher, host, selectionLifetime, null) + : this(dispatcher, host, observations, selection, selectionLifetime, null) { } - internal ProcessClient(ICheatEngineDispatcher dispatcher, IProcessHost host, - TargetSelectionLifetime selectionLifetime, Action? admitStatefulOperation) + internal ProcessClient(ICheatEngineDispatcher dispatcher, IProcessHost host, IRuntimeObservationPort observations, + IProcessSelectionPort selection, TargetSelectionLifetime selectionLifetime, + Action? admitStatefulOperation, CoreLifetime? lifetime = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _host = host ?? throw new ArgumentNullException(nameof(host)); + _observations = observations ?? throw new ArgumentNullException(nameof(observations)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selectionLifetime = selectionLifetime ?? throw new ArgumentNullException(nameof(selectionLifetime)); _admitStatefulOperation = admitStatefulOperation; + _lifetime = lifetime; } - public bool TryGetCurrent( + public bool TryGetCurrentProcess( out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryReadCurrent("Processes.GetCurrent", out snapshot, out failure, cancellationToken); + return TryReadCurrent("Processes.GetCurrentProcess", out snapshot, out failure, cancellationToken); } - public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default) + public ProcessSnapshot GetCurrentProcess(CancellationToken cancellationToken = default) { - if (TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + if (TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) { return snapshot; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -83,7 +110,7 @@ public ProcessSnapshot Refresh(CancellationToken cancellationToken = default) return snapshot; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -101,35 +128,49 @@ public bool TryAttach( Admit("Processes.Attach"); CurrentProcessCapture captured = default; - if (!_dispatcher.TryInvoke( - () => - { - _host.OpenProcess(processId.Value); - captured = CaptureCurrent("Processes.Attach"); - }, - out failure, - cancellationToken)) + bool invoked = _dispatcher.TryInvoke(() => captured = SelectAndCapture(), out failure, cancellationToken); + ReportSelectionAdvance(captured.Advance, "Processes.Attach"); + if (!invoked) { snapshot = default; return false; } - if (!TryGetCapturedSnapshot(captured, "Processes.Attach", out snapshot, out failure)) - { - return false; - } + return TryGetCapturedSnapshot(captured, "Processes.Attach", out snapshot, out failure); - if (snapshot.Id != processId) + // The only caller of SelectAndObserve (architecture ratchet): select, then observe Cheat Engine's selection again + // so the selection epoch follows whatever it now selects, even when the selection is refused. + CurrentProcessCapture SelectAndCapture() { - snapshot = default; - failure = new CheatEngineFailure( - CheatEngineFailureKind.OperationRejected, - "Processes.Attach", - "Cheat Engine did not select the requested process."); - return false; - } + ProcessOperationStatus status; + try + { + // SelectAndObserve also resets Cheat Engine's configured pointer size (spike C3 D3(c)). + status = _selection.SelectAndObserve(processId, out _); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new CurrentProcessCapture(exception); + } - return true; + CurrentProcessCapture observed = CaptureCurrent("Processes.Attach"); + if (!status.IsSuccess) + { + return new CurrentProcessCapture(CurrentProcessCaptureFailure.SelectionRefused, status) + { + Advance = observed.Advance + }; + } + + // Cheat Engine confirmed the selection; a different PID now means that it changed again since. + return observed.Failure == CurrentProcessCaptureFailure.None && observed.Snapshot.Id != processId + ? new CurrentProcessCapture(CurrentProcessCaptureFailure.Unobserved, + ProcessOperationStatus.TargetChanged) + { + Advance = observed.Advance + } + : observed; + } } public ProcessSnapshot Attach(TargetProcessId processId, CancellationToken cancellationToken = default) @@ -139,7 +180,7 @@ public ProcessSnapshot Attach(TargetProcessId processId, CancellationToken cance return snapshot; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -154,7 +195,8 @@ public bool TryAttachExactName( if (cancellationToken.IsCancellationRequested) { snapshot = default; - failure = Cancelled("Processes.AttachExactName"); + failure = CancellationMapping.BeforeNativeCall("Processes.AttachExactName", + "The operation was cancelled before process-host admission."); return false; } @@ -164,7 +206,7 @@ public bool TryAttachExactName( matches = _host.FindProcessesByExactName(expectedName); } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception - or PlatformNotSupportedException) + or PlatformNotSupportedException) { snapshot = default; failure = new CheatEngineFailure( @@ -201,150 +243,218 @@ public bool TryAttachExactName( public ProcessSnapshot AttachExactName(string processName, CancellationToken cancellationToken = default) { if (TryAttachExactName(processName, out ProcessSnapshot snapshot, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return snapshot; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } - public bool TryAttachForeground( - out ProcessSnapshot snapshot, + public bool TryGetLocalProcesses( + LocalProcessEnumerationRequest request, + out LocalProcessEnumerationResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryUnavailable("Processes.AttachForeground", out snapshot, out failure, cancellationToken); + // An offline diagnostic of the local catalog: no activation admission and no Cheat Engine dispatch. + return LocalProcessCatalog.TryEnumerate(_host, request, out result, out failure, cancellationToken); } - public ProcessSnapshot AttachForeground(CancellationToken cancellationToken = default) + public LocalProcessEnumerationResult GetLocalProcesses( + LocalProcessEnumerationRequest request, + CancellationToken cancellationToken = default) { - if (TryAttachForeground(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + if (TryGetLocalProcesses(request, out LocalProcessEnumerationResult result, out CheatEngineFailure failure, + cancellationToken)) { - return snapshot; + return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } - public bool TryCreate( - ProcessStartRequest request, - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) + /// + /// + /// An incarnation describes a local process. The ISA and width of the owner's target are not observed here, so the + /// last values known for the same selection are kept, as for any observation that leaves a fact unknown. + /// + public TargetSelectionBinding BindOwner(TargetProcessIncarnation incarnation, string operation) { - ValidateStartRequest(request); - return TryUnavailable("Processes.Create", out snapshot, out failure, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ProcessSelection bound = new(new TargetProcessId(incarnation.ProcessId), TargetBackend.LocalProcess, + CheatEngineArchitecture.Unknown, PointerSize.Unknown, incarnation); + lock (_selectionGate) + { + _ = RecordSelection(bound, operation, out SelectionAdvance? advance); + return new TargetSelectionBinding(_selectionLifetime.Epoch, advance?.Reason); + } } - public ProcessSnapshot Create(ProcessStartRequest request, CancellationToken cancellationToken = default) + /// + public void ReportBinding(TargetSelectionBinding binding, string operation) { - if (TryCreate(request, out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + if (binding.AdvanceReason is { } reason) { - return snapshot; + ReportSelectionAdvance(new SelectionAdvance(binding.SelectionEpoch, reason), operation); } - - failure.Throw(); - return default; } - public bool TryPause( + private bool TryReadCurrent( + string operation, out ProcessSnapshot snapshot, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - return TryUnavailable("Processes.Pause", out snapshot, out failure, cancellationToken); - } - - public ProcessSnapshot Pause(CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { - if (TryPause(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + // The read is admitted like any dispatch, under the name of the public call instead of the dispatcher's. + _lifetime?.ThrowIfDispatchRefused(operation); + CurrentProcessCapture captured = default; + bool invoked = _dispatcher.TryInvoke(() => captured = CaptureCurrent(operation), out failure, + cancellationToken); + ReportSelectionAdvance(captured.Advance, operation); + if (!invoked) { - return snapshot; + snapshot = default; + return false; } - failure.Throw(); - return default; + return TryGetCapturedSnapshot(captured, operation, out snapshot, out failure); } - public bool TryResumeExecution( - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) + /// + /// Reports a selection-epoch advance recorded by the dispatched capture, after the callback returned (EventId + /// 1100): the epochs and the reason only, never the process identifier or name. + /// + private void ReportSelectionAdvance(SelectionAdvance? advance, string operation) { - return TryUnavailable("Processes.Resume", out snapshot, out failure, cancellationToken); + if (advance is { } advanced && _lifetime is { } lifetime) + { + lifetime.Diagnostics.TargetSelectionAdvanced(lifetime.Epoch, advanced.SelectionEpoch, operation, + advanced.Reason); + } } - public ProcessSnapshot ResumeExecution(CancellationToken cancellationToken = default) + /// + /// Observes the selected target through (CheatEngine.SDK reads the PID + /// before and after the facts), then, for a local process only, its incarnation and optional local metadata. A + /// status other than success is returned as a classified failure; an SDK fault of a Cheat Engine or local-catalog + /// call is captured and returned as a failure. + /// + private CurrentProcessCapture CaptureCurrent(string operation) { - if (TryResumeExecution(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + ObservedTarget target; + try { - return snapshot; + target = TargetArchitectureObserver.Observe(_observations); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new CurrentProcessCapture(exception); } - failure.Throw(); - return default; - } + if (target.NoTargetSelected || target.Backend == TargetBackend.FileAsProcess) + { + // No process, or a file opened as a process, is selected: an earlier process selection no longer holds. + return new CurrentProcessCapture( + target.NoTargetSelected + ? CurrentProcessCaptureFailure.NoTargetSelected + : CurrentProcessCaptureFailure.Unobserved, target.Status) + { + Advance = ClearObservedSelection(operation) + }; + } - public bool TryGetPauseState( - out ProcessPauseSnapshot snapshot, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - return TryUnavailable("Processes.GetPauseState", out snapshot, out failure, cancellationToken); - } + if (target.ProcessId is not { } id) + { + return new CurrentProcessCapture(CurrentProcessCaptureFailure.Unobserved, target.Status); + } - public ProcessPauseSnapshot GetPauseState(CancellationToken cancellationToken = default) - { - if (TryGetPauseState(out ProcessPauseSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + // A local creation time or catalog entry describes a local process only, never a CEServer target or a PID whose + // backend is not established (audit A12-05). + IncarnationRead incarnation = IncarnationRead.Unknown; + LocalProcessInfo process = default; + try { - return snapshot; + if (target.Backend == TargetBackend.LocalProcess) + { + incarnation = ReadIncarnation(id); + if (incarnation.SelectionChanged) + { + return new CurrentProcessCapture(CurrentProcessCaptureFailure.Unobserved, + ProcessOperationStatus.TargetChanged); + } + + if (_host.TryGetLocalProcess(id.Value, out LocalProcessInfo local)) + { + if (local.Id != id.Value) + { + return new CurrentProcessCapture(CurrentProcessCaptureFailure.InvalidLocalMetadata, + target.Status, local.Id); + } + + process = local; + } + } + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return new CurrentProcessCapture(exception); } - failure.Throw(); - return default; + ProcessSnapshot snapshot = ObserveSelection( + new ProcessSelection(id, target.Backend, target.Architecture, target.Bitness, incarnation.Incarnation), + process, target.ConfiguredPointerSizeBytes, operation, out SelectionAdvance? advance); + return new CurrentProcessCapture(snapshot) + { + Advance = advance + }; } - private bool TryReadCurrent( - string operation, - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, - CancellationToken cancellationToken) + /// + /// Reads the incarnation of the selected local process: the last known incarnation of the same PID is checked + /// with ValidateCurrent, otherwise the selection is observed. A failed or unqualified observation leaves + /// the incarnation unknown, which is no evidence of a change; another PID, no target, a file opened as a process + /// or a CEServer backend means that the selection changed after the target facts were read. + /// + private IncarnationRead ReadIncarnation(TargetProcessId id) { - CurrentProcessCapture captured = default; - if (!_dispatcher.TryInvoke(() => captured = CaptureCurrent(operation), out failure, cancellationToken)) + TargetProcessIncarnation? known; + lock (_selectionGate) { - snapshot = default; - return false; + known = _lastSelection is { Incarnation: { } last } previous && previous.Id == id ? last : null; } - return TryGetCapturedSnapshot(captured, operation, out snapshot, out failure); - } - - private CurrentProcessCapture CaptureCurrent(string operation) - { - long processId = _host.GetOpenedProcessId(); - if (processId is <= 0 or > int.MaxValue) + if (known is { } expected) { - ClearObservedSelection(operation); - return new CurrentProcessCapture(CurrentProcessCaptureFailure.NoTargetSelected); + TargetIdentityFacts check = _observations.ValidateSelection(expected); + return RuntimeObservationMapping.ToIncarnationComparison(check.Kind) switch + { + IncarnationComparison.Current => IncarnationRead.Of(expected), + IncarnationComparison.ProcessReused when check.Observed.Incarnation is { } reused => + IncarnationRead.Of(reused), + IncarnationComparison.SelectionChanged => IncarnationRead.Changed, + _ => IncarnationRead.Unknown + }; } - TargetProcessId id = new(checked((int) processId)); - bool hasLocalMetadata = _host.TryGetLocalProcess(id.Value, out LocalProcessInfo process); - if (hasLocalMetadata && process.Id != id.Value) + TargetSelectionFacts observed = _observations.ObserveSelection(); + if (observed.SelectedProcessId is { } selected && selected != id.Value) { - return new CurrentProcessCapture(CurrentProcessCaptureFailure.InvalidLocalMetadata, process.Id); + return IncarnationRead.Changed; } - CheatEngineArchitecture architecture = TryGetTargetArchitecture(); - return new CurrentProcessCapture(ObserveSelection(id, hasLocalMetadata ? process : default, architecture, - operation)); + return RuntimeObservationMapping.ToSelectionIdentity(observed.Status) switch + { + SelectionIdentity.Qualified when observed.Incarnation is { } incarnation => IncarnationRead.Of(incarnation), + SelectionIdentity.NoTarget or SelectionIdentity.FileAsProcess or SelectionIdentity.RemoteBackend => + IncarnationRead.Changed, + _ => IncarnationRead.Unknown + }; } - private static bool TryGetCapturedSnapshot( + private bool TryGetCapturedSnapshot( CurrentProcessCapture captured, string operation, out ProcessSnapshot snapshot, @@ -360,6 +470,16 @@ private static bool TryGetCapturedSnapshot( snapshot = default; failure = captured.Failure switch { + // A fault of the selection call, of an observation or of the local catalog: whether a selection change + // happened is unknown, so the host effect stays unknown. + CurrentProcessCaptureFailure.Faulted => SdkBoundary.Translate(operation, captured.Fault!, + CheatEngineHostEffect.Unknown, _lifetime), + // SelectAndObserve refused or could not confirm the selection: what Cheat Engine now selects is unknown. + CurrentProcessCaptureFailure.SelectionRefused => RuntimeObservationMapping.ToFailure(operation, + captured.Status, CheatEngineHostEffect.Unknown), + // The SDK operation returned a factual status: its call completed without establishing target facts. + CurrentProcessCaptureFailure.Unobserved => RuntimeObservationMapping.ToFailure(operation, captured.Status, + CheatEngineHostEffect.Completed), CurrentProcessCaptureFailure.NoTargetSelected => new CheatEngineFailure( CheatEngineFailureKind.TargetNotAttached, operation, @@ -378,54 +498,101 @@ private static bool TryGetCapturedSnapshot( return false; } - private CheatEngineArchitecture TryGetTargetArchitecture() + /// + /// Records the observed selection and advances the selection epoch when the PID changed, when the same PID denotes + /// another incarnation, or when a known backend, ISA or process width changed to a different known value. An + /// unknown fact neither advances the epoch nor erases the last fact known for the same selection, so a transient + /// observation failure does not invalidate target-bound leases and does not weaken the identity to the PID alone. + /// + private ProcessSnapshot ObserveSelection(ProcessSelection observed, LocalProcessInfo process, + int? configuredPointerSizeBytes, string operation, out SelectionAdvance? advance) { - try - { - return _host.GetTargetArchitecture(); - } - catch (EngineGlobalUnavailableException) - { - return CheatEngineArchitecture.Unknown; - } - catch (EngineCapabilityUnavailableException) + lock (_selectionGate) { - return CheatEngineArchitecture.Unknown; + ProcessSelection selection = RecordSelection(observed, operation, out advance); + // The configured pointer size is a fact of this observation, not of the selection identity, so it is never + // merged with an earlier value. Local metadata and the start time describe a local process only. + bool isLocal = selection.Backend == TargetBackend.LocalProcess; + return new ProcessSnapshot( + selection.Id, + isLocal ? process.Name : null, + isLocal ? process.ExecutablePath : null, + selection.Backend, + selection.Architecture, + selection.Width, + configuredPointerSizeBytes, + isLocal && selection.Incarnation is { } incarnation + ? new DateTimeOffset(incarnation.StartedAtUtcTicks, TimeSpan.Zero) + : null, + _selectionLifetime.Epoch); } } - private ProcessSnapshot ObserveSelection( - TargetProcessId id, - LocalProcessInfo process, - CheatEngineArchitecture architecture, - string operation) + /// + /// Records an observed selection under , which the caller holds: the epoch advances when + /// finds a change, otherwise the observation is merged into the last one. + /// + private ProcessSelection RecordSelection(ProcessSelection observed, string operation, + out SelectionAdvance? advance) { - lock (_selectionGate) + advance = null; + ProcessSelection selection = observed; + if (_lastSelection is { } previous) { - ProcessSelection selection = new(id, architecture); - if (_lastSelection is { } previous && previous != selection) + if (GetChangeReason(previous, observed) is { } reason) + { + advance = new SelectionAdvance(_selectionLifetime.Advance(operation), reason); + } + else { - _selectionLifetime.Advance(operation); + selection = previous.Merge(observed); } + } - _lastSelection = selection; - return new ProcessSnapshot(id, process.Name, process.ExecutablePath, - architecture, - _selectionLifetime.Epoch); + _lastSelection = selection; + return selection; + } + + private static string? GetChangeReason(ProcessSelection previous, ProcessSelection current) + { + if (previous.Id != current.Id) + { + return "PidChanged"; + } + + if (previous.Incarnation is { } before && current.Incarnation is { } after && before != after) + { + return "ProcessReused"; } + + if (previous.Backend != TargetBackend.Unknown && current.Backend != TargetBackend.Unknown && + previous.Backend != current.Backend) + { + return "BackendChanged"; + } + + if (previous.Architecture != CheatEngineArchitecture.Unknown && + current.Architecture != CheatEngineArchitecture.Unknown && previous.Architecture != current.Architecture) + { + return "ArchitectureChanged"; + } + + return previous.Width.IsKnown && current.Width.IsKnown && previous.Width.Bytes != current.Width.Bytes + ? "WidthChanged" + : null; } - private void ClearObservedSelection(string operation) + private SelectionAdvance? ClearObservedSelection(string operation) { lock (_selectionGate) { if (_lastSelection is null) { - return; + return null; } _lastSelection = null; - _selectionLifetime.Advance(operation); + return new SelectionAdvance(_selectionLifetime.Advance(operation), "TargetDetached"); } } @@ -450,68 +617,73 @@ private static string NormalizeExactProcessName(string processName) return normalized; } - private static void ValidateStartRequest(ProcessStartRequest request) + private void Admit(string operation) { - if (string.IsNullOrWhiteSpace(request.ExecutablePath)) - { - throw new ArgumentException("The executable path must not be empty.", nameof(request)); - } - - if (!Path.IsPathFullyQualified(request.ExecutablePath)) - { - throw new ArgumentException("The executable path must be absolute.", nameof(request)); - } + _admitStatefulOperation?.Invoke(operation); + } - if (request.WorkingDirectory is { } directory && !Path.IsPathFullyQualified(directory)) + /// The identity of one observed selection: PID, backend, ISA, process width and local incarnation. + private readonly record struct ProcessSelection( + TargetProcessId Id, + TargetBackend Backend, + CheatEngineArchitecture Architecture, + PointerSize Width, + TargetProcessIncarnation? Incarnation) + { + /// Keeps every fact of and the last known value of each unknown one. + internal ProcessSelection Merge(ProcessSelection current) { - throw new ArgumentException("The working directory must be absolute when specified.", nameof(request)); + return new ProcessSelection(current.Id, + current.Backend == TargetBackend.Unknown ? Backend : current.Backend, + current.Architecture == CheatEngineArchitecture.Unknown ? Architecture : current.Architecture, + current.Width.IsKnown ? current.Width : Width, + current.Incarnation ?? Incarnation); } } - private bool TryUnavailable( - string operation, - out T result, - out CheatEngineFailure failure, - CancellationToken cancellationToken) + /// The incarnation read of one capture: a known incarnation, unknown, or a changed selection. + private readonly record struct IncarnationRead(TargetProcessIncarnation? Incarnation, bool SelectionChanged) { - Admit(operation); - result = default!; - failure = cancellationToken.IsCancellationRequested - ? Cancelled(operation) - : new CheatEngineFailure( - CheatEngineFailureKind.CapabilityUnavailable, - operation, - "This operation requires a validated Cheat Engine process-control binding."); - return false; - } + internal static IncarnationRead Unknown => default; - private void Admit(string operation) - { - _admitStatefulOperation?.Invoke(operation); - } + internal static IncarnationRead Changed => new(null, true); - private static CheatEngineFailure Cancelled(string operation) - { - return new CheatEngineFailure( - CheatEngineFailureKind.Cancelled, - operation, - "The operation was cancelled before process-host admission."); + internal static IncarnationRead Of(TargetProcessIncarnation incarnation) + { + return new IncarnationRead(incarnation, false); + } } - private readonly record struct ProcessSelection(TargetProcessId Id, CheatEngineArchitecture Architecture); + /// A selection-epoch advance made by a capture: the new epoch and the closed reason name. + private readonly record struct SelectionAdvance(long SelectionEpoch, string Reason); private readonly record struct CurrentProcessCapture( ProcessSnapshot Snapshot, CurrentProcessCaptureFailure Failure, - int ObservedProcessId) + ProcessOperationStatus Status, + int ObservedProcessId, + Exception? Fault) { + /// Gets the selection-epoch advance this capture made, reported after the dispatched callback returned. + internal SelectionAdvance? Advance + { + get; + init; + } + internal CurrentProcessCapture(ProcessSnapshot snapshot) - : this(snapshot, CurrentProcessCaptureFailure.None, 0) + : this(snapshot, CurrentProcessCaptureFailure.None, ProcessOperationStatus.Success, 0, null) + { + } + + internal CurrentProcessCapture(CurrentProcessCaptureFailure failure, ProcessOperationStatus status, + int observedProcessId = 0) + : this(default, failure, status, observedProcessId, null) { } - internal CurrentProcessCapture(CurrentProcessCaptureFailure failure, int observedProcessId = 0) - : this(default, failure, observedProcessId) + internal CurrentProcessCapture(Exception fault) + : this(default, CurrentProcessCaptureFailure.Faulted, default, 0, fault) { } } @@ -520,6 +692,9 @@ private enum CurrentProcessCaptureFailure { None, NoTargetSelected, - InvalidLocalMetadata + InvalidLocalMetadata, + Unobserved, + SelectionRefused, + Faulted } } diff --git a/libs/CheatEngine.Client.Core/Domains/RecordLookupStatus.cs b/libs/CheatEngine.Client.Core/Domains/RecordLookupStatus.cs index 5b6316e..ee272b2 100644 --- a/libs/CheatEngine.Client.Core/Domains/RecordLookupStatus.cs +++ b/libs/CheatEngine.Client.Core/Domains/RecordLookupStatus.cs @@ -6,5 +6,8 @@ internal enum RecordLookupStatus Success, NotFound, AddressListUnavailable, - InvalidRecord + InvalidRecord, + + /// The table holds more records than the caller's materialization limit; nothing was copied. + LimitExceeded } diff --git a/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs b/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs deleted file mode 100644 index 6259d50..0000000 --- a/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.RemoteExecution; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Domains.RemoteExecution; - -/// Preserves remote-execution intent contracts until allocation and thread-affinity live gates are complete. -internal sealed class UnavailableRemoteExecutionClient : IRemoteExecutionClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableRemoteExecutionClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryInjectLibrary(RemoteDllInjectionRequest request, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - failure = CreateFailure("RemoteExecution.InjectLibrary", cancellationToken); - return false; - } - - public void InjectLibrary(RemoteDllInjectionRequest request, CancellationToken cancellationToken = default) - { - _ = TryInjectLibrary(request, out CheatEngineFailure failure, cancellationToken); - UnavailableCapabilityFailure.Throw(failure); - } - - public bool TryInvoke(RemoteCallRequest request, out RemoteCallResult result, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - result = default; - failure = CreateFailure("RemoteExecution.Invoke", cancellationToken); - return false; - } - - public RemoteCallResult Invoke(RemoteCallRequest request, CancellationToken cancellationToken = default) - { - _ = TryInvoke(request, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) - { - return UnavailableCapabilityFailure.Create(_lifetime, "Remote execution and injection", operation, - cancellationToken); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs index 4159b54..00c0d85 100644 --- a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs @@ -1,47 +1,94 @@ +using System.Collections.Immutable; +using System.Reflection; + using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Qualification; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; -using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Runtime; namespace CheatEngine.Client.Core.Domains; /// Captures only independently observed, synchronous runtime facts from the active Cheat Engine host. +/// +/// The facts are the read-only CheatEngine.SDK 2.0.0 runtime observations, through : +/// the snapshot reports what the SDK established and leaves every other fact unknown. +/// internal sealed class RuntimeClient : ICheatEngineRuntime { + private const string SnapshotOperation = "Runtime.GetSnapshot"; + + private const string CapabilityOperation = "Runtime.GetClientCapability"; + private readonly Version _clientAssemblyVersion; + private readonly string? _clientVersion; + private readonly ICoreDiagnostics _diagnostics; private readonly ICheatEngineDispatcher _dispatcher; private readonly Func _getEpoch; private readonly Func _isActivationCurrent; + private readonly CoreLifetime? _lifetime; private readonly CoreClientPolicy _policy; - private readonly IRuntimeProbe _probe; + private readonly IRuntimeObservationPort _port; + private readonly HostQualificationRecord? _qualificationEvidence; private readonly Version _sdkAssemblyVersion; + private readonly ConsumedSdkIdentity _sdkIdentity; internal RuntimeClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime, CoreClientPolicy policy) : this( dispatcher, - new LuaRuntimeProbe(), + SdkRuntimeObservationPort.Instance, () => lifetime.Epoch, typeof(ICheatEngineRuntime).Assembly.GetName().Version, typeof(RuntimeInfo).Assembly.GetName().Version, policy, - () => lifetime.IsCurrent) + () => lifetime.IsCurrent, + ConsumedSdkIdentity.Current, + lifetime.Diagnostics, + HostQualificationEvidence.Recorded) { ArgumentNullException.ThrowIfNull(lifetime); + _lifetime = lifetime; } + /// Creates a runtime client with explicit seams; tests supply the port and the consumed-SDK identity. + /// The dispatcher that runs the read-only observations on Cheat Engine's main thread. + /// The read-only runtime observation port. + /// Reads the current activation epoch. + /// The Client assembly version, or the version of this build. + /// The SDK runtime assembly version, or the loaded one. + /// The activation policy, or the safe defaults. + /// Reads whether the activation is current; always current when omitted. + /// + /// The consumed-SDK identity evidence; means that no identity was embedded, so every + /// operational package gate stays unknown. + /// + /// The diagnostics sink; nothing is emitted when omitted. + /// + /// The host qualification evidence; means that no run is recorded, so every qualification + /// gate stays unknown. + /// + /// + /// The Client version the qualification gate compares with the evidence, without build metadata; the + /// informational version of this build when omitted. + /// internal RuntimeClient( ICheatEngineDispatcher dispatcher, - IRuntimeProbe probe, + IRuntimeObservationPort port, Func getEpoch, Version? clientAssemblyVersion = null, Version? sdkAssemblyVersion = null, CoreClientPolicy? policy = null, - Func? isActivationCurrent = null) + Func? isActivationCurrent = null, + ConsumedSdkIdentity? sdkIdentity = null, + ICoreDiagnostics? diagnostics = null, + HostQualificationRecord? qualificationEvidence = null, + string? clientVersion = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + _diagnostics = GuardedCoreDiagnostics.Wrap(diagnostics); + _sdkIdentity = sdkIdentity ?? ConsumedSdkIdentity.NotEmbedded; + _port = port ?? throw new ArgumentNullException(nameof(port)); _getEpoch = getEpoch ?? throw new ArgumentNullException(nameof(getEpoch)); _isActivationCurrent = isActivationCurrent ?? (static () => true); _policy = policy ?? CoreClientPolicy.SafeDefaults; @@ -49,6 +96,26 @@ internal RuntimeClient( throw new InvalidOperationException("The Client assembly does not declare an assembly version."); _sdkAssemblyVersion = sdkAssemblyVersion ?? typeof(RuntimeInfo).Assembly.GetName().Version ?? throw new InvalidOperationException("The SDK runtime assembly does not declare an assembly version."); + _qualificationEvidence = qualificationEvidence; + _clientVersion = clientVersion ?? HostQualificationGate.WithoutMetadata(typeof(ICheatEngineRuntime).Assembly + .GetCustomAttribute()?.InformationalVersion); + } + + /// + /// Creates the qualification gate reason of every capability while this build embeds no host qualification + /// evidence (; audit A20-19): receipts produced for the SDK branch never + /// qualify the Client tuple. The tuple names the consumed CheatEngine.SDK identity this build embeds, never a version + /// written in the source. + /// + /// The consumed-SDK identity evidence of this build. + internal static string QualificationUnknownReason(ConsumedSdkIdentity sdkIdentity) + { + ArgumentNullException.ThrowIfNull(sdkIdentity); + string package = sdkIdentity.ExpectedInformationalVersion is { } identity + ? "CheatEngine.SDK " + identity + : "the consumed CheatEngine.SDK (this build embeds no identity for it)"; + return $"No Client qualification receipt for profile {ConsumedSdkIdentity.SupportedHostProfileId} with {package} " + + "is embedded in this build; SDK-branch receipts never qualify the Client tuple."; } public long Epoch => _getEpoch(); @@ -58,15 +125,7 @@ public bool TryGetSnapshot( out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - CheatEngineRuntimeSnapshot captured = default; - if (!_dispatcher.TryInvoke(() => captured = Capture(), out failure, cancellationToken)) - { - snapshot = default; - return false; - } - - snapshot = captured; - return true; + return TryCapture(SnapshotOperation, out snapshot, out failure, cancellationToken); } public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToken = default) @@ -76,51 +135,7 @@ public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToke return snapshot; } - failure.Throw(); - return default; - } - - public bool TryGetSdkCapability( - RuntimeCapabilityId capability, - out RuntimeCapabilityAvailability availability, - out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - if (capability.IsEmpty) - { - throw new ArgumentException("A runtime capability identifier is required.", nameof(capability)); - } - - if (!TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) - { - availability = default; - return false; - } - - if (snapshot.SdkCapabilities.TryGet(capability, out availability)) - { - return true; - } - - availability = new RuntimeCapabilityAvailability( - capability, - RuntimeCapabilityAvailabilityState.Unknown, - RuntimeCapabilityContract.Unknown); - failure = default; - return true; - } - - public RuntimeCapabilityAvailability GetSdkCapability( - RuntimeCapabilityId capability, - CancellationToken cancellationToken = default) - { - if (TryGetSdkCapability(capability, out RuntimeCapabilityAvailability availability, - out CheatEngineFailure failure, cancellationToken)) - { - return availability; - } - - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -135,21 +150,21 @@ public bool TryGetClientCapability( throw new ArgumentException("A Client capability identifier is required.", nameof(capability)); } - if (!TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) + if (!TryCapture(CapabilityOperation, out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) { availability = default; return false; } - if (snapshot.ClientCapabilities.TryGet(capability, out availability)) + if (snapshot.Capabilities.TryGet(capability, out availability)) { return true; } - availability = new ClientCapabilityAvailability( - capability, - ClientCapabilityAvailabilityState.Unknown, + ClientCapabilityEvidenceGate undefined = UnknownEvidence( "This Client release does not define a probe or policy gate for the requested capability."); + availability = new ClientCapabilityAvailability(capability, + new ClientCapabilityEvidence(undefined, undefined, undefined, undefined, undefined, undefined)); failure = default; return true; } @@ -159,122 +174,128 @@ public ClientCapabilityAvailability GetClientCapability( CancellationToken cancellationToken = default) { if (TryGetClientCapability(capability, out ClientCapabilityAvailability availability, - out CheatEngineFailure failure, cancellationToken)) + out CheatEngineFailure failure, cancellationToken)) { return availability; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } private CheatEngineRuntimeSnapshot Capture() { - ProbeResult version = ValidateVersion(Probe(_probe.GetCheatEngineVersion)); - ProbeResult systemArchitecture = Probe(_probe.GetSystemArchitecture); - ProbeResult targetAbi = Probe(_probe.GetTargetAbi); - ProbeResult openedProcess = ValidateOpenedProcess(Probe(_probe.GetOpenedProcessId)); - - bool hasTarget = openedProcess.HasValue && openedProcess.Value > 0; - ProbeResult targetArchitecture = hasTarget - ? Probe(_probe.TargetIs64Bit) - : ProbeResult.Unknown("No target process is selected, so target architecture was not probed."); - - double? observedVersion = version.HasValue ? version.Value : null; - CheatEngineArchitecture decodedSystemArchitecture = DecodeSystemArchitecture(ref systemArchitecture); - TargetAbi decodedTargetAbi = DecodeTargetAbi(ref targetAbi); - CheatEngineArchitecture decodedTargetArchitecture = - DecodeTargetArchitecture(ref targetArchitecture, decodedTargetAbi); - - RuntimeCapabilityAvailability[] capabilities = - [ - version.ToAvailability(RuntimeCapabilityId.CheatEngineVersion), - systemArchitecture.ToAvailability(RuntimeCapabilityId.SystemArchitecture), - targetArchitecture.ToAvailability(RuntimeCapabilityId.TargetArchitecture), - targetAbi.ToAvailability(RuntimeCapabilityId.TargetAbi) - ]; - + ObservedRuntime observed = RuntimeObserver.Observe(_port); + CheatEngineHostObservation host = observed.Host; + ObservedTarget target = observed.Target; + PointerSize cheatEngineBitness = host.CheatEngineIs64Bit switch + { + true => PointerSize.Bit64, + false => PointerSize.Bit32, + null => PointerSize.Unknown + }; return new CheatEngineRuntimeSnapshot( Epoch, - new CheatEngineRuntimeVersionInfo(observedVersion, CheatEngineVersion.Ce77010621, _clientAssemblyVersion, - _sdkAssemblyVersion), + new CheatEngineRuntimeVersionInfo(host.FileVersion, CheatEngineVersion.Ce77010621, _clientAssemblyVersion, + _sdkAssemblyVersion, _sdkIdentity.LoadedInformationalVersion, _sdkIdentity.ExactReviewedIdentity), new CheatEngineRuntimePlatformInfo( - decodedSystemArchitecture, - decodedTargetArchitecture, - PointerSize.FromArchitecture(decodedTargetArchitecture), - decodedTargetAbi), - RuntimeCapabilities.Create(capabilities), - CreateClientCapabilities(openedProcess)); + host.OperatingSystem, + host.SystemArchitecture, + cheatEngineBitness, + target.Backend, + target.Architecture, + target.Bitness, + target.Abi, + target.IsAndroid, + target.ConfiguredPointerSizeBytes), + CreateClientCapabilities(observed.ProcessSelectionHost, new HostQualificationContext( + _sdkIdentity.ExactReviewedIdentity, _sdkIdentity.LoadedInformationalVersion, host.FileVersion, + cheatEngineBitness, host.OperatingSystem, target.Backend, target.Architecture, _clientVersion))); } - private ClientCapabilities CreateClientCapabilities(ProbeResult openedProcess) + private ClientCapabilities CreateClientCapabilities(ClientCapabilityEvidenceGate selectedProcess, + HostQualificationContext qualificationContext) { ClientCapabilityEvidenceGate lifetime = _isActivationCurrent() ? Satisfied("The Client activation is current.") : Missing("The Client activation is no longer current."); - ClientCapabilityEvidenceGate packageUnknown = UnknownEvidence( - "The runtime snapshot does not establish the identity of the consumed SDK package artifact."); - ClientCapabilityEvidenceGate qualificationUnknown = UnknownEvidence( - "No complete Cheat Engine 7.7 x64 live qualification record is attached to this capability observation."); + // ADR-09, ADR-10: the package gate of every capability comes from evidence (the embedded consumed-SDK identity + // compared with the loaded CheatEngine.SDK.Engine), never from the presence of an interface or a version name. + ClientCapabilityEvidenceGate package = _sdkIdentity.PackageGate; + string noQualificationEvidence = QualificationUnknownReason(_sdkIdentity); ClientCapabilityEvidenceGate policyNotRequired = Satisfied( "This capability has no additional activation policy opt-in."); ClientCapabilityEvidenceGate unprobedHost = UnknownEvidence( "The runtime snapshot does not probe every host primitive required by this capability."); ClientCapabilityEvidenceGate implemented = Satisfied( "The Client composes an operational adapter for this capability."); - ClientCapabilityEvidenceGate contractOnly = Missing( - "The Client package currently composes only an unavailable adapter for this capability."); - ClientCapabilityAvailability[] capabilities = - [ - Describe(ClientCapabilityId.ProcessSelection, implemented, packageUnknown, openedProcess.Evidence, - qualificationUnknown, policyNotRequired, lifetime), - Describe(ClientCapabilityId.TypedMemory, implemented, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.PatternScanning, implemented, packageUnknown, unprobedHost, - qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.ValueScanning, contractOnly, - Missing( - "CheatEngine.SDK 1.0.0 does not provide the public MemScan and FoundList ownership factory required by Client."), - unprobedHost, qualificationUnknown, policyNotRequired, lifetime), - Describe(ClientCapabilityId.Inspection, implemented, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Tables, implemented, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.ProtectedLua, implemented, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.UnsafeLuaExecution, implemented, packageUnknown, unprobedHost, - qualificationUnknown, - _policy.EnableUnsafeLuaExecution - ? Satisfied("Unsafe Lua execution was explicitly enabled for this activation.") - : Missing( - "Unsafe Lua execution requires explicit EnableUnsafeLuaExecution opt-in for this activation."), - lifetime), - Describe(ClientCapabilityId.Allocations, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Assembly, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.RemoteExecution, contractOnly, packageUnknown, unprobedHost, - qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Debugger, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Hotkeys, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Timers, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Speed, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Hashing, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime), - Describe(ClientCapabilityId.Dbvm, contractOnly, packageUnknown, unprobedHost, qualificationUnknown, - policyNotRequired, lifetime) - ]; + ClientCapabilityEvidenceGate unsafeLuaPolicy = _policy.EnableUnsafeLuaExecution + ? Satisfied("Unsafe Lua execution was explicitly enabled for this activation.") + : Missing("Unsafe Lua execution requires explicit EnableUnsafeLuaExecution opt-in for this activation."); + ClientCapabilityEvidenceGate autoAssemblerPolicy = _policy.EnableAutoAssemblerPatches + ? Satisfied("Auto Assembler patches were explicitly enabled for this activation.") + : Missing("Auto Assembler patches require explicit EnableAutoAssemblerPatches opt-in for this activation."); + + // Every capability is composed from its one catalog row; only the implementation reason (experimental or not), + // the policy gate and the host gate vary. + ImmutableArray catalog = ClientCapabilityCatalog.Entries; + ClientCapabilityAvailability[] capabilities = new ClientCapabilityAvailability[catalog.Length]; + for (int index = 0; index < catalog.Length; index++) + { + ClientCapabilityDescriptor entry = catalog[index]; + capabilities[index] = Describe( + entry.Id, + entry.ExperimentalDiagnosticId is { } experimental + ? Satisfied(ExperimentalImplementationReason(experimental)) + : implemented, + package, + entry.Host == CapabilityHostSource.SdkSelectedProcess ? selectedProcess : unprobedHost, + HostQualificationGate.Evaluate(entry, _qualificationEvidence, qualificationContext, noQualificationEvidence), + entry.Policy switch + { + CapabilityPolicySource.UnsafeLuaExecutionOptIn => unsafeLuaPolicy, + CapabilityPolicySource.AutoAssemblerPatchesOptIn => autoAssemblerPolicy, + _ => policyNotRequired + }, + lifetime); + } return ClientCapabilities.Create(capabilities); } + /// Captures one snapshot for the public call , which names its failure. + private bool TryCapture(string operation, out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + // The observations are read-only (Q45) and report their outcomes as statuses; an SDK fault (for example a + // detached runtime) is returned as a failure, never thrown across a Try method. The activation is admitted + // like any dispatch, under the name of the public call instead of the dispatcher's. + _lifetime?.ThrowIfDispatchRefused(operation); + CheatEngineRuntimeSnapshot captured = default; + if (!SdkBoundary.TryInvoke(_dispatcher, operation, () => captured = Capture(), + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) + { + snapshot = default; + return false; + } + + snapshot = captured; + _diagnostics.RuntimeSnapshotCaptured(captured.Epoch, captured.Platform.TargetArchitecture, + captured.Platform.TargetBitness.Bytes, captured.Platform.ConfiguredPointerSizeBytes ?? 0, + captured.Platform.ConfiguredPointerSizeDiffersFromBitness == true); + return true; + } + + /// The implementation gate reason of an operational capability whose public API is experimental. + /// The diagnostic id of the experimental API, for example CECLIENT5001. + /// The reason. + internal static string ExperimentalImplementationReason(string diagnosticId) + { + return "The Client composes an operational adapter for this capability; its API is experimental (" + + diagnosticId + ") until its live scenarios pass."; + } + private static ClientCapabilityAvailability Describe( ClientCapabilityId capability, ClientCapabilityEvidenceGate implementation, @@ -302,94 +323,4 @@ private static ClientCapabilityEvidenceGate UnknownEvidence(string reason) { return new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, reason); } - - private static ProbeResult ValidateVersion(ProbeResult probe) - { - return probe.HasValue && probe.Value is { } version && (!double.IsFinite(version) || version < 0) - ? ProbeResult.Malformed("Cheat Engine returned a version that is not a finite non-negative number.") - : probe; - } - - private static ProbeResult ValidateOpenedProcess(ProbeResult probe) - { - return probe.HasValue && probe.Value is { } processId && - (processId < 0 || processId > int.MaxValue) - ? ProbeResult.Malformed( - "Cheat Engine returned an opened process identifier outside the supported PID range.") - : probe; - } - - private static CheatEngineArchitecture DecodeSystemArchitecture(ref ProbeResult probe) - { - if (!probe.HasValue) - { - return CheatEngineArchitecture.Unknown; - } - - if (RuntimeInfo.TryDecodeSystemArchitecture(probe.Value, out CheatEngineArchitecture architecture)) - { - return architecture; - } - - probe = ProbeResult.Malformed("Cheat Engine returned an unsupported system architecture code."); - return CheatEngineArchitecture.Unknown; - } - - private static TargetAbi DecodeTargetAbi(ref ProbeResult probe) - { - if (!probe.HasValue) - { - return TargetAbi.Unknown; - } - - if (RuntimeInfo.TryDecodeTargetAbi(probe.Value, out TargetAbi targetAbi)) - { - return targetAbi; - } - - probe = ProbeResult.Malformed("Cheat Engine returned an unsupported target ABI code."); - return TargetAbi.Unknown; - } - - private static CheatEngineArchitecture DecodeTargetArchitecture(ref ProbeResult probe, TargetAbi targetAbi) - { - if (!probe.HasValue || targetAbi == TargetAbi.Unknown) - { - return CheatEngineArchitecture.Unknown; - } - - if (targetAbi != TargetAbi.Windows) - { - probe = ProbeResult.Unknown( - "The target architecture probe is not qualified for the observed target ABI."); - return CheatEngineArchitecture.Unknown; - } - - return probe.Value ? CheatEngineArchitecture.X64 : CheatEngineArchitecture.X86; - } - - private static ProbeResult Probe(Func probe) - { - try - { - return ProbeResult.Available(probe()); - } - catch (EngineGlobalUnavailableException) - { - return ProbeResult.MissingGlobal(); - } - catch (EngineCapabilityUnavailableException) - { - return ProbeResult.MissingCapability(); - } - catch (EngineMarshallingException) - { - return ProbeResult.Malformed("The Cheat Engine runtime probe returned a malformed result."); - } - catch (EngineException exception) - { - return ProbeResult.Faulted( - $"The Cheat Engine runtime probe failed with {exception.GetType().Name}."); - } - } } diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeObservationMapping.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeObservationMapping.cs new file mode 100644 index 0000000..613d4ec --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeObservationMapping.cs @@ -0,0 +1,267 @@ +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Maps the statuses of the CheatEngine.SDK 2.0.0 runtime and process operations to the Client vocabulary, value by +/// value. +/// +/// +/// +/// Every value of , and +/// has a deliberate Client counterpart. A value this Client +/// version does not know takes the conservative fallback (an indeterminate failure, or unknown evidence), never +/// an established outcome; the mapping-totality tests fail when the consumed SDK adds a value. +/// +/// +/// Process-operation failures: +/// +/// +/// +/// SDK status +/// Client failure kind +/// +/// TargetNotAttachedTargetNotAttached +/// SelectionNotConfirmedOperationRejected +/// GlobalUnavailableCapabilityUnavailable +/// ProtectedLuaFailureLuaError +/// InvalidResultInvalidHostResult +/// TargetChangedTargetChanged +/// FileAsProcessTargetTargetIdentityUnavailable +/// Unknown or undefinedIndeterminateHostResult +/// +/// +/// Success is not a failure and maps to , which no caller +/// reports. The target-selection statuses (, +/// ) map to what they establish about the selected process's identity: +/// a failed or empty observation is never evidence of a change. +/// +/// +internal static class RuntimeObservationMapping +{ + /// Returns the Client failure kind of a non-successful process-operation status. + /// The SDK process-operation status kind. + /// The failure kind; for an undefined value. + internal static CheatEngineFailureKind ToFailureKind(ProcessOperationStatusKind kind) + { + return kind switch + { + ProcessOperationStatusKind.Success => CheatEngineFailureKind.Unknown, + ProcessOperationStatusKind.TargetNotAttached => CheatEngineFailureKind.TargetNotAttached, + ProcessOperationStatusKind.SelectionNotConfirmed => CheatEngineFailureKind.OperationRejected, + ProcessOperationStatusKind.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + ProcessOperationStatusKind.ProtectedLuaFailure => CheatEngineFailureKind.LuaError, + ProcessOperationStatusKind.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + ProcessOperationStatusKind.TargetChanged => CheatEngineFailureKind.TargetChanged, + ProcessOperationStatusKind.FileAsProcessTarget => CheatEngineFailureKind.TargetIdentityUnavailable, + ProcessOperationStatusKind.Unknown => CheatEngineFailureKind.IndeterminateHostResult, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Creates the failure of a non-successful process-operation status. + /// The public Client operation name. + /// The SDK status; a successful status is a caller bug. + /// What is known about the Cheat Engine side effect. + /// The classified failure, with a stable message that names no process, path or address. + /// is successful. + internal static CheatEngineFailure ToFailure(string operation, ProcessOperationStatus status, + CheatEngineHostEffect hostEffect) + { + if (status.IsSuccess) + { + throw new ArgumentException("A successful process-operation status is not a failure.", nameof(status)); + } + + return new CheatEngineFailure(ToFailureKind(status.Kind), operation, DescribeFailure(status.Kind), null, + hostEffect); + } + + /// + /// Returns the host evidence a target observation gives about the selected-process primitive when + /// CheatEngine.SDK produced no runtime snapshot. + /// + /// The status of the Client's own target observation. + /// + /// when the selection was read (a target, no target), + /// for an absent global, + /// for a raising global, + /// for a malformed value, and + /// when the observation cannot be attributed to one target + /// or has no capability profile (file as process), or for an undefined value. + /// + internal static ClientCapabilityEvidenceState ToHostEvidenceState(ProcessOperationStatusKind kind) + { + return kind switch + { + ProcessOperationStatusKind.Success => ClientCapabilityEvidenceState.Satisfied, + ProcessOperationStatusKind.TargetNotAttached => ClientCapabilityEvidenceState.Satisfied, + ProcessOperationStatusKind.GlobalUnavailable => ClientCapabilityEvidenceState.Missing, + ProcessOperationStatusKind.ProtectedLuaFailure => ClientCapabilityEvidenceState.Faulted, + ProcessOperationStatusKind.InvalidResult => ClientCapabilityEvidenceState.Malformed, + ProcessOperationStatusKind.SelectionNotConfirmed => ClientCapabilityEvidenceState.Unknown, + ProcessOperationStatusKind.TargetChanged => ClientCapabilityEvidenceState.Unknown, + ProcessOperationStatusKind.FileAsProcessTarget => ClientCapabilityEvidenceState.Unknown, + ProcessOperationStatusKind.Unknown => ClientCapabilityEvidenceState.Unknown, + _ => ClientCapabilityEvidenceState.Unknown + }; + } + + /// Returns the evidence of one host fact read on its own. + /// The status of the SDK host operation. + /// + /// only for a successful read, whose value is kept; every + /// other status leaves the fact unknown: an absent global is , + /// a raising call or an exhausted stack is , a value of the + /// wrong shape is , and a raw nil (for the file + /// version, Cheat Engine returning no version) or an undefined value is + /// . + /// + internal static ClientCapabilityEvidenceState ToFactEvidenceState(LuaOperationStatusKind kind) + { + return kind switch + { + LuaOperationStatusKind.Success => ClientCapabilityEvidenceState.Satisfied, + LuaOperationStatusKind.GlobalUnavailable => ClientCapabilityEvidenceState.Missing, + LuaOperationStatusKind.LuaFailure => ClientCapabilityEvidenceState.Faulted, + LuaOperationStatusKind.StackUnavailable => ClientCapabilityEvidenceState.Faulted, + LuaOperationStatusKind.NilResult => ClientCapabilityEvidenceState.Unknown, + LuaOperationStatusKind.InvalidResult => ClientCapabilityEvidenceState.Malformed, + LuaOperationStatusKind.MissingResult => ClientCapabilityEvidenceState.Malformed, + LuaOperationStatusKind.ResultCapacityExceeded => ClientCapabilityEvidenceState.Malformed, + LuaOperationStatusKind.Unknown => ClientCapabilityEvidenceState.Unknown, + _ => ClientCapabilityEvidenceState.Unknown + }; + } + + /// Returns the host evidence of a capability that CheatEngine.SDK probed in its runtime snapshot. + /// The SDK availability of the probed capability. + /// + /// for an available primitive, + /// for an unavailable one, otherwise + /// . + /// + internal static ClientCapabilityEvidenceState ToHostEvidenceState(RuntimeCapabilityAvailabilityState state) + { + return state switch + { + RuntimeCapabilityAvailabilityState.Available => ClientCapabilityEvidenceState.Satisfied, + RuntimeCapabilityAvailabilityState.Unavailable => ClientCapabilityEvidenceState.Missing, + RuntimeCapabilityAvailabilityState.Unknown => ClientCapabilityEvidenceState.Unknown, + _ => ClientCapabilityEvidenceState.Unknown + }; + } + + /// Returns what a selection observation establishes about the identity of the selected process. + /// The SDK selection observation status. + /// The identity evidence; for an undefined value. + internal static SelectionIdentity ToSelectionIdentity(TargetSelectionObservationStatus status) + { + return status switch + { + TargetSelectionObservationStatus.CurrentTargetQualified => SelectionIdentity.Qualified, + TargetSelectionObservationStatus.CurrentTargetUnqualified => SelectionIdentity.Unqualified, + TargetSelectionObservationStatus.CurrentTargetRemoteBackend => SelectionIdentity.RemoteBackend, + TargetSelectionObservationStatus.CurrentTargetBackendUnknown => SelectionIdentity.BackendUnknown, + TargetSelectionObservationStatus.CurrentTargetFileAsProcess => SelectionIdentity.FileAsProcess, + TargetSelectionObservationStatus.NoTargetSelected => SelectionIdentity.NoTarget, + TargetSelectionObservationStatus.GlobalUnavailable => SelectionIdentity.Unavailable, + TargetSelectionObservationStatus.LuaFailure => SelectionIdentity.Unavailable, + TargetSelectionObservationStatus.InvalidResult => SelectionIdentity.Unavailable, + TargetSelectionObservationStatus.Unspecified => SelectionIdentity.Unavailable, + _ => SelectionIdentity.Unavailable + }; + } + + /// Returns what an incarnation check establishes about the selected process. + /// The SDK identity check kind. + /// The comparison; for an undefined value. + internal static IncarnationComparison ToIncarnationComparison(TargetIdentityCheckKind kind) + { + return kind switch + { + TargetIdentityCheckKind.Current => IncarnationComparison.Current, + TargetIdentityCheckKind.ProcessReused => IncarnationComparison.ProcessReused, + TargetIdentityCheckKind.TargetChanged => IncarnationComparison.SelectionChanged, + TargetIdentityCheckKind.NoTargetSelected => IncarnationComparison.SelectionChanged, + TargetIdentityCheckKind.RemoteBackend => IncarnationComparison.SelectionChanged, + TargetIdentityCheckKind.FileAsProcess => IncarnationComparison.SelectionChanged, + TargetIdentityCheckKind.CurrentTargetUnqualified => IncarnationComparison.Unavailable, + TargetIdentityCheckKind.BackendUnknown => IncarnationComparison.Unavailable, + TargetIdentityCheckKind.GlobalUnavailable => IncarnationComparison.Unavailable, + TargetIdentityCheckKind.LuaFailure => IncarnationComparison.Unavailable, + TargetIdentityCheckKind.InvalidResult => IncarnationComparison.Unavailable, + TargetIdentityCheckKind.Unspecified => IncarnationComparison.Unavailable, + _ => IncarnationComparison.Unavailable + }; + } + + private static string DescribeFailure(ProcessOperationStatusKind kind) + { + return kind switch + { + ProcessOperationStatusKind.TargetNotAttached => "Cheat Engine has no selected target process.", + ProcessOperationStatusKind.SelectionNotConfirmed => + "Cheat Engine did not confirm the requested process as its selected target.", + ProcessOperationStatusKind.GlobalUnavailable => + "A Cheat Engine global that the target observation requires is not available in this host.", + ProcessOperationStatusKind.ProtectedLuaFailure => + "A Cheat Engine global of the target observation raised a protected Lua error.", + ProcessOperationStatusKind.InvalidResult => + "Cheat Engine returned a target observation outside its documented shape.", + ProcessOperationStatusKind.TargetChanged => + "Cheat Engine's selected target changed while the Client observed it, so the observation cannot be " + + "attributed to one target; read the current process again.", + ProcessOperationStatusKind.FileAsProcessTarget => + "Cheat Engine's selected target is a file opened as a process: it has no process identity, so no " + + "target fact is attributed to it.", + _ => "Cheat Engine reported no result for the target observation." + }; + } +} + +/// What a selection observation establishes about the identity of the selected process. +internal enum SelectionIdentity +{ + /// The observation failed or recorded nothing: no identity evidence, and no evidence of a change. + Unavailable = 0, + + /// A local process with its incarnation (PID and observed creation time). + Qualified = 1, + + /// A local process whose incarnation could not be established. + Unqualified = 2, + + /// A process served by CEServer: a local incarnation does not describe it. + RemoteBackend = 3, + + /// A PID whose backend is not established: no local incarnation can be compared. + BackendUnknown = 4, + + /// No target is selected. + NoTarget = 5, + + /// A file opened as a process is selected; it has no process identity. + FileAsProcess = 6 +} + +/// What comparing a known incarnation with the current selection establishes. +internal enum IncarnationComparison +{ + /// No comparable incarnation was observed: no evidence of a change. + Unavailable = 0, + + /// The selection still denotes the known incarnation. + Current = 1, + + /// The same PID now denotes another process (a different creation time). + ProcessReused = 2, + + /// The selection moved away from the known process (another PID, no target, another backend). + SelectionChanged = 3 +} diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeObserver.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeObserver.cs new file mode 100644 index 0000000..1ebbab7 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeObserver.cs @@ -0,0 +1,126 @@ +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Observes the facts of one runtime snapshot through CheatEngine.SDK 2.0.0, keeping every fact that can be +/// established when another one cannot (audit ADR-09, F08). +/// +/// +/// +/// The first observation is RuntimeObservations.TryObserveRuntimeInfo: the host facts, then the target +/// facts, in one Lua admission, with the SDK's own capability observations. No selected target is a legitimate +/// snapshot, so is an absent global. +/// +/// +/// The SDK produces no snapshot for a file opened as a process, a target change, or a host or target global that +/// raised, returned nil or returned a malformed value. The observer then reads the host facts with +/// RuntimeHostOperations.ObserveHost, and each fact alone when that fails too, and the target through +/// . The host evidence of process selection then comes from the target +/// observation's status () +/// instead of the SDK's capability list. +/// +/// +internal static class RuntimeObserver +{ + /// Observes the host and the selected target. + /// The read-only runtime observation port. + /// The copied facts. + internal static ObservedRuntime Observe(IRuntimeObservationPort port) + { + ArgumentNullException.ThrowIfNull(port); + ProcessOperationStatus status = port.TryObserveRuntimeInfo(out RuntimeInfo? info); + if (status.IsSuccess && info is { Host: { } host }) + { + // Without target facts the SDK reports either no selected target or an absent required global + // (getOpenedProcessID or targetIs64Bit), which its capability list records as unavailable. + ObservedTarget target = info.Target is { } facts + ? new ObservedTarget(ProcessOperationStatus.Success, facts, null) + : new ObservedTarget( + info.Capabilities.GetState(RuntimeCapabilityId.CurrentProcess) == + RuntimeCapabilityAvailabilityState.Unavailable || + info.Capabilities.GetState(RuntimeCapabilityId.TargetArchitecture) == + RuntimeCapabilityAvailabilityState.Unavailable + ? ProcessOperationStatus.GlobalUnavailable + : ProcessOperationStatus.TargetNotAttached, default, null); + return new ObservedRuntime(status, host, target, FromSdkCapability(info.Capabilities)); + } + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + return new ObservedRuntime(status, ObserveHost(port), observed, + FromTargetObservation(status, observed.Status)); + } + + /// Reads the host facts together, or one by one when one of them cannot be read. + private static CheatEngineHostObservation ObserveHost(IRuntimeObservationPort port) + { + LuaOperationStatus status = port.ObserveHost(out CheatEngineHostObservation host); + if (status.IsSuccess) + { + return host; + } + + CheatEngineVersion? version = Keeps(port.TryGetCheatEngineFileVersion(out CheatEngineVersion fileVersion)) + ? fileVersion + : null; + CheatEngineArchitecture architecture = + Keeps(port.TryGetSystemArchitecture(out CheatEngineArchitecture systemArchitecture)) + ? systemArchitecture + : CheatEngineArchitecture.Unknown; + bool? is64Bit = Keeps(port.TryIsCheatEngine64Bit(out bool cheatEngineIs64Bit)) ? cheatEngineIs64Bit : null; + CheatEngineOperatingSystem operatingSystem = + Keeps(port.TryGetOperatingSystem(out CheatEngineOperatingSystem reported)) + ? reported + : CheatEngineOperatingSystem.Unknown; + return new CheatEngineHostObservation(version, architecture, is64Bit, operatingSystem); + } + + private static bool Keeps(LuaOperationStatus status) + { + return RuntimeObservationMapping.ToFactEvidenceState(status.Kind) == ClientCapabilityEvidenceState.Satisfied; + } + + private static ClientCapabilityEvidenceGate FromSdkCapability(RuntimeCapabilities capabilities) + { + if (!capabilities.TryGet(RuntimeCapabilityId.CurrentProcess, out RuntimeCapabilityAvailability availability)) + { + return new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, + "CheatEngine.SDK did not probe the selected-process primitive (Process.Current) in this snapshot."); + } + + ClientCapabilityEvidenceState state = RuntimeObservationMapping.ToHostEvidenceState(availability.State); + return new ClientCapabilityEvidenceGate(state, state switch + { + ClientCapabilityEvidenceState.Satisfied => + "CheatEngine.SDK observed the selected-process primitive (Process.Current) in this snapshot.", + ClientCapabilityEvidenceState.Missing => + "CheatEngine.SDK reports the selected-process primitive (Process.Current) unavailable in this host.", + _ => "CheatEngine.SDK could not establish the selected-process primitive (Process.Current)." + }); + } + + private static ClientCapabilityEvidenceGate FromTargetObservation(ProcessOperationStatus runtimeStatus, + ProcessOperationStatus targetStatus) + { + return new ClientCapabilityEvidenceGate(RuntimeObservationMapping.ToHostEvidenceState(targetStatus.Kind), + $"CheatEngine.SDK produced no runtime snapshot ({runtimeStatus.Kind}); the selected-process observation " + + $"reported {targetStatus.Kind}."); + } +} + +/// The copied facts of one runtime snapshot. +/// The status of RuntimeObservations.TryObserveRuntimeInfo. +/// The host facts; a fact that could not be read is or unknown. +/// The target observation. +/// +/// The host evidence of the selected-process primitive: the SDK's Process.Current capability entry, or the +/// status of the target observation when the SDK produced no snapshot. +/// +internal sealed record ObservedRuntime( + ProcessOperationStatus RuntimeInfoStatus, + CheatEngineHostObservation Host, + ObservedTarget Target, + ClientCapabilityEvidenceGate ProcessSelectionHost); diff --git a/libs/CheatEngine.Client.Core/Domains/ScanOptionTranslation.cs b/libs/CheatEngine.Client.Core/Domains/ScanOptionTranslation.cs new file mode 100644 index 0000000..ebbff00 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ScanOptionTranslation.cs @@ -0,0 +1,102 @@ +using System.Globalization; + +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Checks and writes the public scan options in Cheat Engine's own text, once for both scan routes: the AOB options +/// () and the value-scan first request (ValueScanRequests). +/// +/// +/// The public values validate themselves when they are created; only a tampered value can be undefined. Each route +/// throws for one, found with +/// and , before the +/// activation check and before dispatch, so the translation never sees one; an undefined requirement or alignment +/// writes nothing. +/// +internal static class ScanOptionTranslation +{ + /// Gets whether every requirement of a filter is defined; only tampering can make one undefined. + /// The filter. + /// when each of the three requirements is a defined value. + internal static bool IsDefined(ScanProtectionFilter protection) + { + return Enum.IsDefined(protection.Executable) && Enum.IsDefined(protection.CopyOnWrite) && + Enum.IsDefined(protection.Writable); + } + + /// Gets whether an alignment rule is one its factories can produce. + /// The rule. + /// for no alignment, a positive divisor alone or non-empty digits alone. + internal static bool IsDefined(ScanAlignment alignment) + { + return alignment.Mode switch + { + ScanAlignmentMode.None => alignment is { Divisor: 0, Digits: null }, + ScanAlignmentMode.AlignedTo => alignment is { Divisor: > 0, Digits: null }, + ScanAlignmentMode.LastDigits => alignment is { Divisor: 0, Digits.Length: > 0 }, + _ => false + }; + } + + /// Writes a protection filter in Cheat Engine's clause grammar. + /// The filter. + /// + /// The clauses in Cheat Engine's order X, C, W (+ required, - excluded, + /// * either); the empty string, which CheatEngine.SDK documents as Cheat Engine's "find everything" + /// value, when every attribute is unspecified. + /// + internal static string ToProtectionText(ScanProtectionFilter protection) + { + Span text = stackalloc char[6]; + int written = 0; + AppendClause(text, ref written, protection.Executable, 'X'); + AppendClause(text, ref written, protection.CopyOnWrite, 'C'); + AppendClause(text, ref written, protection.Writable, 'W'); + return written == 0 ? string.Empty : new string(text[..written]); + } + + /// Writes an alignment rule as Cheat Engine's fast-scan method and parameter. + /// The rule. + /// + /// The parameter the route sends without alignment: the AOB options omit it (), and the + /// positional value-scan request sends the empty string. + /// + /// + /// The method and its parameter: the decimal divisor for , the + /// upper-case digits for , and + /// for . + /// + internal static (FastScanMethod Method, string? Parameter) ToFastScan(ScanAlignment alignment, + string? unalignedParameter) + { + return alignment.Mode switch + { + ScanAlignmentMode.AlignedTo => (FastScanMethod.Aligned, + alignment.Divisor.ToString(CultureInfo.InvariantCulture)), + ScanAlignmentMode.LastDigits => (FastScanMethod.LastDigits, alignment.Digits ?? unalignedParameter), + _ => (FastScanMethod.NotAligned, unalignedParameter) + }; + } + + private static void AppendClause(Span text, ref int written, ScanProtectionRequirement requirement, + char flag) + { + char mode = requirement switch + { + ScanProtectionRequirement.Required => '+', + ScanProtectionRequirement.Excluded => '-', + ScanProtectionRequirement.Any => '*', + _ => '\0' + }; + if (mode == '\0') + { + return; + } + + text[written++] = mode; + text[written++] = flag; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ScanResourceLimits.cs b/libs/CheatEngine.Client.Core/Domains/ScanResourceLimits.cs new file mode 100644 index 0000000..d3d8b00 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ScanResourceLimits.cs @@ -0,0 +1,26 @@ +namespace CheatEngine.Client.Core.Domains; + +/// The fixed managed buffer limits of the scan routes that copy into a caller-sized destination. +/// +/// A limit bounds the managed memory one call can claim, whatever a request asks for. It is a Client choice, not a +/// Cheat Engine or CheatEngine.SDK limit, and it never bounds Cheat Engine's own scan work. +/// +internal static class ScanResourceLimits +{ + /// + /// The largest destination of the bounded AOB route: AobScanRequest.MaximumResults + 1 addresses, capped + /// here. The extra slot proves truncation, so the route copies at most MaximumPatternMatches - 1 addresses; + /// the global route applies the same copy limit, so one request's answer does not depend on the route. + /// + /// + /// 65,536 addresses are 512 KiB; CheatEngine.SDK stages them in a pooled buffer of the same length, so the call's + /// managed peak stays near 1 MiB. + /// + internal const int MaximumPatternMatches = 65_536; + + /// + /// The largest number of value-scan results one read copies (ValueScanReadRequest states this value): each + /// result costs two Cheat Engine calls on the main thread, getAddress and getValue. + /// + internal const int MaximumValueScanPage = 1024; +} diff --git a/libs/CheatEngine.Client.Core/Domains/ScanTermination.cs b/libs/CheatEngine.Client.Core/Domains/ScanTermination.cs new file mode 100644 index 0000000..99e5b55 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ScanTermination.cs @@ -0,0 +1,28 @@ +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// The one stop-confirmation check of a released Cheat Engine scan session, shared by the bounded AOB route +/// () and the value-scan sessions (ValueScanMapping). +/// +internal static class ScanTermination +{ + /// Returns whether no scan could still be running when the session's objects were released. + /// The SDK's termination status of the release. + /// + /// only when no stop was needed or the one cooperative stop was confirmed; an unconfirmed, + /// refused or unrecognized stop is . + /// + internal static bool IsStopConfirmed(MemoryScanTerminationStatus termination) + { + return termination switch + { + MemoryScanTerminationStatus.NotRequired or MemoryScanTerminationStatus.Confirmed => true, + MemoryScanTerminationStatus.Unknown or MemoryScanTerminationStatus.WaitTimedOut + or MemoryScanTerminationStatus.TerminateFailed or MemoryScanTerminationStatus.WaitFailed + or MemoryScanTerminationStatus.NotInvoked => false, + _ => false + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs index 91447fd..367aec7 100644 --- a/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs @@ -1,25 +1,92 @@ using System.Diagnostics.CodeAnalysis; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; /// Production adapter that copies and releases the SDK-owned AOB list on the CE dispatch thread. +/// +/// +/// The global route calls AobScanner.TryScanOutcome with its target context and copies both into an +/// ; classification happens in , never here. +/// +/// +/// The bounded route calls the stable AobScanner.TryScanWithinBounds overload without a call deadline and +/// copies its result, the Cheat Engine error text and the session release into an +/// ; the SDK releases the MemScan session itself, once, before it returns. The +/// target observation that selects the route goes through , the only +/// Client code that calls TargetSelection. +/// +/// +/// The SDK owner is handed to through , so a failure +/// between acquisition and publication releases the Cheat Engine list exactly once (audit F13); a release that is +/// not confirmed then surfaces as an carrying its kind, which +/// reports as CleanupUnconfirmed. After publication +/// the match list is the single release authority, and it releases through the never-throwing +/// Owned<StringList>.ReleaseWithOutcome. +/// +/// internal sealed class SdkAobScanPort : IAobScanPort { - public AobScanHostStatus TryScan(string pattern, AobScanOptions options, - [NotNullWhen(true)] out IAobMatchList? matches) + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches) { matches = null; - if (!AobScanner.TryScan(pattern, options, out Owned? owner)) + AobScanOutcome outcome = AobScanner.TryScanOutcome(pattern, options, out Owned? owner, + out AobScanTargetContext context); + AobHostOutcome host = new(outcome.Kind, outcome.LuaStatus, outcome.ResultCount, + SdkRuntimeObservationPort.Copy(context.Before), SdkRuntimeObservationPort.Copy(context.After)); + + // The SDK hands out an owner only with a successful outcome. The guard keeps a contract break (a success + // without an owner) from reaching OwnershipHandoff.Adopt, whose ArgumentNullException would otherwise escape a + // Try method; PatternScanner classifies that outcome as an invalid host result. An owner handed out with any + // other outcome is still adopted, so PatternScanner releases it once. + if (owner is null) { - return AobScanHostStatus.Rejected; + return host; } - matches = new SdkAobMatchList(owner); - return AobScanHostStatus.Success; + matches = OwnershipHandoff.Adopt(owner, static acquired => new SdkAobMatchList(acquired), + static acquired => SdkReleaseOutcomes.FromTarget(acquired.ReleaseWithOutcome().Status)); + return host; + } + + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + // The stable overload without a call deadline: the deadline overload is experimental (CESDK5010). + AobBoundedScanResult result = + AobScanner.TryScanWithinBounds(pattern, bounds, options, destination, cancellationToken); + return new AobBoundedHostResult + { + Kind = result.Kind, + CreationStatus = result.Creation.Status, + LuaStatus = result.LuaStatus, + HostResultCount = result.HostResultCount, + Written = result.Written, + RowsRead = result.RowsRead, + UnreadHostRows = result.UnreadHostRows, + BelowStartSkipped = result.BelowStartSkipped, + AtOrAfterStopSkipped = result.AtOrAfterStopSkipped, + IsMaterializationLimitReached = result.IsMaterializationLimitReached, + HostErrorText = result.HostErrorText, + IsHostErrorTextTruncated = result.IsHostErrorTextTruncated, + IsHostErrorTextUnreadable = result.IsHostErrorTextUnreadable, + HostScanElapsed = result.HostScanElapsed, + CopyElapsed = result.CopyElapsed, + FoundListRelease = result.Release.FoundList.Status, + MemScanRelease = result.Release.MemScan.Status, + ReleaseTermination = result.Release.Termination + }; + } + + public TargetSelectionFacts ObserveSelection() + { + return SdkRuntimeObservationPort.Instance.ObserveSelection(); } public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) @@ -41,9 +108,17 @@ public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) return _owner.Value.TryGetItem(index, out value); } - public void Dispose() + /// Releases the Cheat Engine list through the SDK owner, once, without throwing. + /// + /// CheatEngine.SDK 2.0.0 Owned<T>.ReleaseWithOutcome always consumes the owner and never retries + /// destroy(): Released after a confirmed destroy, UnconfirmedAfterInvocation when it + /// raised, RefusedRuntimeChanged when the owner belongs to a previous Lua runtime, and NotInvoked + /// when no Lua operation could be admitted. treats every status but + /// Released as an unconfirmed release. + /// + public TargetReleaseStatus Release() { - _owner.Dispose(); + return _owner.ReleaseWithOutcome().Status; } } } diff --git a/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs index 37d5552..772b5ac 100644 --- a/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs @@ -1,6 +1,9 @@ -using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Inspection; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +using SdkSymbolRegistrationLease = CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease; namespace CheatEngine.Client.Core.Domains; @@ -37,24 +40,32 @@ public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo sy return EngineInspection.GetSymbolInfo(expression, out symbol); } - public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, out Address address) { - return EngineInspection.ResolveAddress(expression, options, out address); + return EngineInspection.ResolveAddress(expression, InspectionMapping.ToSdkResolutionOptions(mode), + out address); } - public bool TryResolveName(nuint address, out string? name) + public LuaOperationStatus TryGetName(Address address, out string? name) { - return ClientLuaGlobals.TryGetNameFromAddress(address, out name); + return SymbolRegistry.TryGetName(address, out name); } - public void RegisterSymbol(string name, nuint address, bool doNotSave) + public SymbolRegistrationAttempt TryRegisterOwned(SymbolName name, Address address, + SymbolRegistrationOptions options) { - ClientLuaGlobals.RegisterSymbol(name, address, doNotSave); + SymbolRegistrationAcquireOutcome outcome = SymbolRegistry.TryRegisterOwned(name, address, options); + return new SymbolRegistrationAttempt(outcome.Status, + outcome.Lease is { } lease ? new SdkSymbolRegistrationHandle(lease) : null); } - public void UnregisterSymbol(string name) + /// Adapts the SDK's symbol registration lease, which has no public constructor, to the Core handle. + private sealed class SdkSymbolRegistrationHandle(SdkSymbolRegistrationLease lease) : ISymbolRegistrationHandle { - ClientLuaGlobals.UnregisterSymbol(name); + public SymbolRegistrationReleaseKind Release() + { + return lease.Release().Kind; + } } } diff --git a/libs/CheatEngine.Client.Core/Domains/SdkProcessSelectionPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkProcessSelectionPort.cs new file mode 100644 index 0000000..ac9f125 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkProcessSelectionPort.cs @@ -0,0 +1,30 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; + +namespace CheatEngine.Client.Core.Domains; + +/// Production selection port: CheatEngine.SDK 2.0.0 RuntimeProcessOperations.SelectAndObserve. +/// +/// This type is the only Client code that references SelectAndObserve, and ProcessClient.TryAttach is +/// its only caller; the architecture ratchet (RuntimeProbeCallsOnlyReadOnlySdkOperations) keeps both. No +/// hosted test has a Cheat Engine process or Lua state, so its lines are excluded from the coverage metric; +/// SdkProcessSelectionPortTests proves that the call requires an enabled plugin. +/// +internal sealed class SdkProcessSelectionPort : IProcessSelectionPort +{ + private SdkProcessSelectionPort() + { + } + + /// Gets the stateless production port. + internal static SdkProcessSelectionPort Instance + { + get; + } = new(); + + public ProcessOperationStatus SelectAndObserve(TargetProcessId processId, + out CurrentProcessObservation observation) + { + return RuntimeProcessOperations.SelectAndObserve(processId, out observation); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkRuntimeObservationPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkRuntimeObservationPort.cs new file mode 100644 index 0000000..eae9be9 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkRuntimeObservationPort.cs @@ -0,0 +1,97 @@ +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// Production runtime observation port: each member calls one read-only CheatEngine.SDK 2.0.0 operation. +/// +/// +/// The members copy the SDK's typed results unchanged; the Client neither re-reads a global nor derives a fact the +/// SDK did not report. The callers run them on Cheat Engine's main thread through the dispatcher. +/// +/// +/// This type is the only Client code that references RuntimeObservations, RuntimeHostOperations, +/// TargetSelection and the read-only members of RuntimeProcessOperations; the architecture ratchet +/// (RuntimeProbeCallsOnlyReadOnlySdkOperations) keeps it to read-only operations and away from +/// CheatTableFiles (Q45). No hosted test has a Cheat Engine process or Lua state, so its lines are excluded +/// from the coverage metric; SdkRuntimeObservationPortTests proves that each member requires an enabled +/// plugin. +/// +/// +internal sealed class SdkRuntimeObservationPort : IRuntimeObservationPort +{ + private SdkRuntimeObservationPort() + { + } + + /// Gets the stateless production port. + internal static SdkRuntimeObservationPort Instance + { + get; + } = new(); + + public ProcessOperationStatus TryObserveRuntimeInfo(out RuntimeInfo? info) + { + return RuntimeObservations.TryObserveRuntimeInfo(out info); + } + + public LuaOperationStatus ObserveHost(out CheatEngineHostObservation host) + { + return RuntimeHostOperations.ObserveHost(out host); + } + + public LuaOperationStatus TryGetCheatEngineFileVersion(out CheatEngineVersion version) + { + return RuntimeHostOperations.TryGetCheatEngineFileVersion(out version); + } + + public LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchitecture architecture) + { + return RuntimeHostOperations.TryGetSystemArchitecture(out architecture); + } + + public LuaOperationStatus TryIsCheatEngine64Bit(out bool is64Bit) + { + return RuntimeHostOperations.TryIsCheatEngine64Bit(out is64Bit); + } + + public LuaOperationStatus TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem) + { + return RuntimeHostOperations.TryGetOperatingSystem(out operatingSystem); + } + + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) + { + return RuntimeProcessOperations.ObserveCurrent(out observation); + } + + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) + { + return RuntimeProcessOperations.ObserveTargetArchitecture(out observation); + } + + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize) + { + return RuntimeProcessOperations.TryGetConfiguredPointerSize(out rawBytes, out pointerSize); + } + + public TargetSelectionFacts ObserveSelection() + { + return Copy(TargetSelection.ObserveCurrent()); + } + + public TargetIdentityFacts ValidateSelection(TargetProcessIncarnation expected) + { + TargetIdentityCheck check = TargetSelection.ValidateCurrent(expected); + return new TargetIdentityFacts(check.Kind, Copy(check.Observed)); + } + + /// Copies an SDK target observation, for this port and for the AOB target context. + internal static TargetSelectionFacts Copy(TargetSelectionObservation observation) + { + return new TargetSelectionFacts(observation.Status, observation.Backend, observation.SelectedProcessId, + observation.Incarnation); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkTableFilePort.cs b/libs/CheatEngine.Client.Core/Domains/SdkTableFilePort.cs new file mode 100644 index 0000000..1efb146 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkTableFilePort.cs @@ -0,0 +1,33 @@ +using CheatEngine.SDK.Engine.Tables; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// Production table-file port over CheatEngine.SDK's . +/// +/// CheatEngine.SDK passes the path to Cheat Engine unchanged and applies no file-root policy: the Client's +/// AllowedTableRoots policy has already admitted it. While runs, the +/// SDK refuses address-list mutations issued from the same thread (a script of the table being loaded) with +/// TableLoadInProgress. +/// +internal sealed class SdkTableFilePort : ITableFilePort +{ + private SdkTableFilePort() + { + } + + internal static SdkTableFilePort Instance + { + get; + } = new(); + + public LuaOperationStatus TryLoad(string path, bool merge) + { + return CheatTableFiles.TryLoad(path, merge); + } + + public LuaOperationStatus TrySave(string path) + { + return CheatTableFiles.TrySave(path); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs index 1402c93..db59043 100644 --- a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs @@ -1,10 +1,13 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + using CheatEngine.Client.Tables; using CheatEngine.SDK.Engine.AddressList; namespace CheatEngine.Client.Core.Domains; -/// Protected SDK implementation of Address List record lookups. -internal sealed class SdkTableRecordLookupPort : ITableRecordLookupPort +/// Protected SDK implementation of Address List record lookups and of the hierarchy root. +internal sealed class SdkTableRecordLookupPort : ITableRecordLookupPort, ITableHierarchyPort { public RecordLookupStatus TryGetRecord(int index, out MemoryRecordSnapshot record) { @@ -21,6 +24,57 @@ public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record) return TryLookup(list => list.TryGetSelectedRecord(out MemoryRecord value) ? value : null, out record); } + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table) + { + table = default; + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return RecordLookupStatus.AddressListUnavailable; + } + + if (!list.TryGetCount(out int count)) + { + return RecordLookupStatus.InvalidRecord; + } + + if (count > maximumItems) + { + return RecordLookupStatus.LimitExceeded; + } + + ImmutableArray.Builder records = ImmutableArray.CreateBuilder(count); + for (int index = 0; index < count; index++) + { + if (!list.TryGetMemoryRecord(index, out MemoryRecord value) || + !TableClient.TrySnapshot(value, out MemoryRecordSnapshot snapshot)) + { + return RecordLookupStatus.InvalidRecord; + } + + records.Add(snapshot); + } + + table = new AddressTableSnapshot(records.MoveToImmutable()); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetRoot(MemoryRecordId id, out ITableHierarchyRecord? root) + { + root = null; + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return RecordLookupStatus.AddressListUnavailable; + } + + if (!list.TryGetMemoryRecordById(id, out MemoryRecord value)) + { + return RecordLookupStatus.NotFound; + } + + root = new SdkHierarchyRecord(value); + return RecordLookupStatus.Success; + } + private static RecordLookupStatus TryLookup(Func selector, out MemoryRecordSnapshot record) { @@ -40,4 +94,25 @@ record = default; ? RecordLookupStatus.Success : RecordLookupStatus.InvalidRecord; } + + /// A record reached by a hierarchy copy, read through CheatEngine.SDK's typed getters. + private sealed class SdkHierarchyRecord(MemoryRecord record) : ITableHierarchyRecord + { + public bool TrySnapshot(out MemoryRecordSnapshot snapshot) + { + return TableClient.TrySnapshot(record, out snapshot); + } + + public bool TryGetChild(int index, [NotNullWhen(true)] out ITableHierarchyRecord? child) + { + if (record.TryGetChild(index, out MemoryRecord value)) + { + child = new SdkHierarchyRecord(value); + return true; + } + + child = null; + return false; + } + } } diff --git a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs index 435b188..79dd49a 100644 --- a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs @@ -1,164 +1,176 @@ +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Tables; using CheatEngine.SDK.Engine.AddressList; -using CheatEngine.SDK.Lua.Runtime; -using CheatEngine.SDK.Lua.State; namespace CheatEngine.Client.Core.Domains; -/// Protected SDK implementation of record destruction and parent reassignment. +/// +/// Protected SDK implementation of record creation, deletion, selection, activation and parent reassignment. Delete, +/// parent assignment and activation are CheatEngine.SDK's commands, which resolve +/// the record by identifier in the current list, refuse while a table file loads on the calling thread or after the +/// Lua runtime changed, and report how far they got; the port adds no Lua of its own. +/// internal sealed class SdkTableRecordMutationPort : ITableRecordMutationPort { - public TableRecordMutationStatus TryDelete(MemoryRecordId id) + /// + /// The explicit bound of the parent-chain walk that AddressListMutations.SetParent runs before it assigns + /// a parent; the Client never relies on the SDK's default bound. + /// + /// + /// Cheat Engine tables are shallow; a longer chain above the requested parent is refused as + /// instead of being walked without bound. + /// + internal const int ParentTraversalHops = 4096; + + private static readonly MemoryRecordParentTraversalLimit ParentTraversalLimit = new(ParentTraversalHops); + + /// + /// + /// When initialization, snapshotting or the parent assignment fails, the record created by this call is deleted + /// exactly once through with its identifier. Only a completed delete is + /// ; any other result, a fault, or an identifier that could not be + /// read is , never retried (audit A08-14). + /// + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record) { + record = default; if (!AddressListAccess.TryGetCurrent(out AddressList list)) { - return TableRecordMutationStatus.HostRejected; + return new TableRecordCreation( + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.AddressListUnavailable), + TableRecordRollback.NotRequired); } - if (!list.TryGetMemoryRecordById(id, out MemoryRecord record)) - { - return TableRecordMutationStatus.RecordNotFound; - } - - return record.Handle.TryCallMethod("destroy"u8) - ? TableRecordMutationStatus.Success - : TableRecordMutationStatus.HostRejected; - } - - public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, - out MemoryRecordSnapshot record) - { - record = default; - if (parentId is { } requestedParentId && requestedParentId == childId) + if (!list.TryCreateMemoryRecord(out MemoryRecord value)) { - return TableRecordMutationStatus.InvalidRelationship; + return new TableRecordCreation(TableRecordMutationOutcome.InvalidResultAfterInvocation, + TableRecordRollback.NotRequired); } - TableRecordMutationStatus status = TryResolveMutationContext(childId, parentId, out AddressList list, - out MemoryRecord child, out MemoryRecord parent); - if (status != TableRecordMutationStatus.Success) + if (!value.TryGetId(out MemoryRecordId createdId)) { - return status; + // Without its identifier the created record cannot be deleted through AddressListMutations. + return new TableRecordCreation(TableRecordMutationOutcome.InvalidResultAfterInvocation, + TableRecordRollback.Unconfirmed); } - if (parentId is { } candidateParentId) + TableRecordMutationOutcome outcome; + Exception? fault = null; + try { - status = ValidateParentRelationship(list, childId, candidateParentId, parent); - if (status != TableRecordMutationStatus.Success) + outcome = TryInitializeRecord(value, definition) + ? TryCompleteRecordCreation(value, createdId, definition, out record) + : TableRecordMutationOutcome.InvalidResultAfterInvocation; + if (outcome.IsSuccess) { - return status; + return TableRecordCreation.Created; } } - - return TryAssignParent(child, parent, out record); - } - - private static TableRecordMutationStatus TryResolveMutationContext(MemoryRecordId childId, MemoryRecordId? parentId, - out AddressList list, out MemoryRecord child, out MemoryRecord parent) - { - list = default; - child = default; - parent = MemoryRecord.Null; - if (!AddressListAccess.TryGetCurrent(out list)) - { - return TableRecordMutationStatus.HostRejected; - } - - if (!list.TryGetMemoryRecordById(childId, out child)) + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) { - return TableRecordMutationStatus.RecordNotFound; + outcome = TableRecordMutationOutcome.InvalidResultAfterInvocation; + fault = exception; } - if (parentId is { } parentIdValue && !list.TryGetMemoryRecordById(parentIdValue, out parent)) - { - return TableRecordMutationStatus.ParentNotFound; - } - - return TableRecordMutationStatus.Success; + record = default; + (TableRecordRollback rollback, Exception? rollbackFault) = RollBackCreatedRecord(createdId); + return new TableRecordCreation(outcome, rollback, fault, rollbackFault); } - private static TableRecordMutationStatus ValidateParentRelationship(AddressList list, MemoryRecordId childId, - MemoryRecordId candidateParentId, MemoryRecord parent) + public TableRecordMutationOutcome TryDelete(MemoryRecordId id) { - if (!list.TryGetCount(out int topLevelCount)) - { - return TableRecordMutationStatus.HostRejected; - } + return TableRecordMutationOutcome.From(AddressListMutations.Delete(id)); + } - MemoryRecord current = parent; - return TableParentRelationshipGuard.Validate(childId, candidateParentId, GetMaximumParentHops(topLevelCount), - _ => GetNextParentChainStep(ref current)); + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested) + { + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(id, requested); + MemoryRecordSnapshot? snapshot = TableMapping.CopiesRecord(outcome.Kind) && + TryCopyRecord(id, out MemoryRecordSnapshot copied) + ? copied + : null; + return new TableActivationObservation(outcome.Kind, outcome.Problem, snapshot); } - private static ParentChainStep GetNextParentChainStep(ref MemoryRecord current) + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record) { - ParentReadStatus parentReadStatus = TryReadParent(current, out MemoryRecord next); - if (parentReadStatus == ParentReadStatus.Root) + record = default; + if (!AddressListAccess.TryGetCurrent(out AddressList list)) { - return ParentChainStep.Root; + return TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.AddressListUnavailable); } - if (parentReadStatus != ParentReadStatus.Parent) + if (!list.TryGetMemoryRecordById(id, out MemoryRecord value)) { - return ParentChainStep.HostRejected; + return TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.RecordNotFound); } - current = next; - if (!current.TryGetId(out MemoryRecordId nextId)) + if (!list.TrySetSelectedRecord(value)) { - return ParentChainStep.HostRejected; + return TableRecordMutationOutcome.InvalidResultAfterInvocation; } - return ParentChainStep.Parent(nextId); + return TableClient.TrySnapshot(value, out record) + ? TableRecordMutationOutcome.Succeeded + : TableRecordMutationOutcome.CompletedWithoutSnapshot; } - private static ParentReadStatus TryReadParent(MemoryRecord current, out MemoryRecord parent) + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) { - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - LuaState state = operation.State; - using LuaFrame frame = new(state); - if (!current.Handle.TryGetProperty(state, "Parent"u8).IsOk) + record = default; + TableRecordMutationOutcome outcome = + TableRecordMutationOutcome.From(AddressListMutations.SetParent(childId, parentId, ParentTraversalLimit)); + if (!outcome.IsSuccess) { - parent = default; - return ParentReadStatus.HostRejected; + return outcome; } - if (state.IsNil(-1)) - { - parent = default; - return ParentReadStatus.Root; - } + return TryCopyRecord(childId, out record) ? outcome : TableRecordMutationOutcome.CompletedWithoutSnapshot; + } - return MemoryRecord.TryRead(state, -1, out parent) - ? ParentReadStatus.Parent - : ParentReadStatus.HostRejected; + private static bool TryInitializeRecord(MemoryRecord value, MemoryRecordDefinition definition) + { + return value.TrySetDescription(definition.Description) && + value.TrySetAddressExpression(definition.AddressExpression) && + value.TrySetVariableType(definition.VariableType) && + value.TrySetValue(definition.Value); } - private static TableRecordMutationStatus TryAssignParent(MemoryRecord child, MemoryRecord parent, - out MemoryRecordSnapshot record) + private TableRecordMutationOutcome TryCompleteRecordCreation(MemoryRecord value, MemoryRecordId createdId, + MemoryRecordDefinition definition, out MemoryRecordSnapshot record) { - record = default; - if (!child.Handle.TrySetProperty("Parent"u8, parent) || - !TableClient.TrySnapshot(child, out record)) + if (definition.ParentId is { } parentId) { - return TableRecordMutationStatus.HostRejected; + return TrySetParent(createdId, parentId, out record); } - return TableRecordMutationStatus.Success; + return TableClient.TrySnapshot(value, out record) + ? TableRecordMutationOutcome.Succeeded + : TableRecordMutationOutcome.CompletedWithoutSnapshot; } - private static int GetMaximumParentHops(int topLevelCount) + /// Deletes the record created by this call exactly once and reports whether Cheat Engine confirmed it. + private static (TableRecordRollback Rollback, Exception? Fault) RollBackCreatedRecord(MemoryRecordId createdId) { - const int traversalSlack = 1024; - return topLevelCount > int.MaxValue - traversalSlack - ? int.MaxValue - : Math.Max(1, topLevelCount) + traversalSlack; + try + { + return (AddressListMutations.Delete(createdId).IsCompleted + ? TableRecordRollback.Confirmed + : TableRecordRollback.Unconfirmed, null); + } + catch (Exception exception) when (SdkBoundary.IsSdkFault(exception)) + { + return (TableRecordRollback.Unconfirmed, exception); + } } - private enum ParentReadStatus + /// Copies the current state of one record after a completed command, never merged with the command. + private static bool TryCopyRecord(MemoryRecordId id, out MemoryRecordSnapshot record) { - Root, - Parent, - HostRejected + record = default; + return AddressListAccess.TryGetCurrent(out AddressList list) && + list.TryGetMemoryRecordById(id, out MemoryRecord value) && + TableClient.TrySnapshot(value, out record); } } diff --git a/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs b/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs deleted file mode 100644 index c901356..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs +++ /dev/null @@ -1,49 +0,0 @@ -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Results; -using CheatEngine.Client.Speed; - -namespace CheatEngine.Client.Core.Domains.Speed; - -/// Preserves validated speed semantics until Cheat Engine speed control passes its live-host gate. -internal sealed class UnavailableSpeedClient : ISpeedClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableSpeedClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - multiplier = default; - failure = CreateFailure("Speed.GetMultiplier", cancellationToken); - return false; - } - - public SpeedMultiplier GetMultiplier(CancellationToken cancellationToken = default) - { - _ = TryGetMultiplier(out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } - - public bool TrySetMultiplier(SpeedMultiplier multiplier, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - failure = CreateFailure("Speed.SetMultiplier", cancellationToken); - return false; - } - - public void SetMultiplier(SpeedMultiplier multiplier, CancellationToken cancellationToken = default) - { - _ = TrySetMultiplier(multiplier, out CheatEngineFailure failure, cancellationToken); - UnavailableCapabilityFailure.Throw(failure); - } - - private CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) - { - return UnavailableCapabilityFailure.Create(_lifetime, "Target speed control", operation, cancellationToken); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationAttempt.cs b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationAttempt.cs new file mode 100644 index 0000000..2979b20 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationAttempt.cs @@ -0,0 +1,11 @@ +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// The copied result of SymbolRegistry.TryRegisterOwned: the protected registration status and, only when +/// Cheat Engine registered the name and the SDK published its lease, the handle that releases it. +/// +/// The protected registerSymbol status that CheatEngine.SDK reported. +/// The release handle, or when no lease was created. +internal readonly record struct SymbolRegistrationAttempt(LuaOperationStatus Status, ISymbolRegistrationHandle? Handle); diff --git a/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs index 918fff3..2a82877 100644 --- a/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs +++ b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs @@ -1,70 +1,92 @@ +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; -/// Activation-owned release path for a custom symbol that Client registered in Cheat Engine. -internal sealed class SymbolRegistrationLease( - SymbolRegistration registration, - ICheatEngineDispatcher dispatcher, - Action untrack, - Action unregisterSymbol, - Action releaseName) : ISymbolRegistrationLease +/// The activation-owned lease of one symbol that CheatEngine.SDK's ownership coordinator registered. +/// +/// +/// ran the collision pre-check before the registration (audit A14-25, A14-39); +/// the release delegates to the SDK lease (SymbolRegistrationLease.Release), which unregisters the name +/// only while it still resolves to the leased address and no newer registration of the name through the SDK +/// coordinator superseded it. maps its kind: +/// +/// +/// +/// SDK kind +/// Client outcome +/// +/// ReleasedReleased, Completed +/// AlreadyReleasedAlreadyReleased, NotStarted +/// SupersededSuperseded, NotStarted +/// StaleRuntimeRefusedRuntimeChanged, NotStarted +/// CleanupUnavailableCleanupUnavailable, NotStarted (retryable) +/// CleanupIndeterminateCleanupUnconfirmed, Started +/// ReplacedReplaced, NotStarted +/// ExternallyRemovedExternallyRemoved, NotStarted +/// Unknown or an undefined kindUnknown, Unknown (retryable) +/// +/// +/// runs the release on Cheat Engine's main thread, keeps a retryable outcome +/// registered with the activation and reports an incomplete one at deactivation (audit Q43). Once an outcome is +/// no longer retryable the lease owns nothing that a later attempt could release, so it gives the +/// activation-local name reservation back, including after an SDK fault; a retryable outcome keeps it. +/// +/// +internal sealed class SymbolRegistrationLease : HostResourceLease, ISymbolRegistrationLease { - private readonly ICheatEngineDispatcher _dispatcher = - dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + /// The stable operation name of the release, the only text its logs and reports carry. + internal const string ReleaseOperation = "Inspection.Release"; - private readonly Lock _gate = new(); - private readonly Action _releaseName = releaseName ?? throw new ArgumentNullException(nameof(releaseName)); + /// What the base lease records when the SDK release faults: a call may have begun. + private static readonly LeaseReleaseOutcome FaultedOutcome = + new(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown); - private readonly Action _unregisterSymbol = - unregisterSymbol ?? throw new ArgumentNullException(nameof(unregisterSymbol)); + private readonly ISymbolRegistrationHandle _handle; + private readonly Action _releaseName; - private readonly Action _untrack = - untrack ?? throw new ArgumentNullException(nameof(untrack)); - - private int _released; + /// Creates the lease of one registration. + /// The registered name and address. + /// The SDK release handle of the registration. + /// The activation dispatcher that runs the release on Cheat Engine's main thread. + /// Gives the activation-local name reservation back. + /// The activation diagnostics; nothing is logged when omitted. + internal SymbolRegistrationLease(SymbolRegistration registration, ISymbolRegistrationHandle handle, + ICheatEngineDispatcher dispatcher, Action releaseName, ICoreDiagnostics? diagnostics = null) + : base(ReleaseOperation, dispatcher, diagnostics) + { + _handle = handle ?? throw new ArgumentNullException(nameof(handle)); + _releaseName = releaseName ?? throw new ArgumentNullException(nameof(releaseName)); + Name = registration.Name; + Address = registration.Address; + } public string Name { get; - } = registration.Name; + } public Address Address { get; - } = registration.Address; - - public bool IsReleased => Volatile.Read(ref _released) != 0; + } - public void Dispose() + protected override LeaseReleaseOutcome ReleaseOnMainThread() { - lock (_gate) + LeaseReleaseOutcome outcome = FaultedOutcome; + try { - if (Volatile.Read(ref _released) != 0) - { - return; - } - - // Keep the lease active and registered when normal dispatch admission is closed during disable. The hosting - // cleanup scope can then retry this exact disposal on CE's main thread before Lua detaches. - _dispatcher.Invoke(() => _unregisterSymbol(Name)); - - try - { - _untrack(this); - } - finally + outcome = SdkReleaseOutcomes.FromSymbolRegistration(_handle.Release()); + return outcome; + } + finally + { + if (!outcome.IsRetryable) { - try - { - _releaseName(Name); - } - finally - { - Volatile.Write(ref _released, 1); - } + _releaseName(Name); } } } diff --git a/libs/CheatEngine.Client.Core/Domains/TableActivationObservation.cs b/libs/CheatEngine.Client.Core/Domains/TableActivationObservation.cs new file mode 100644 index 0000000..598126e --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableActivationObservation.cs @@ -0,0 +1,34 @@ +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// The copied facts of one AddressListMutations.SetActive command and the record it left. +/// +/// CheatEngine.SDK reads the record's Active state before the change, calls the setter at most once and never +/// retries, then reads Active and AsyncProcessing back; is its classification. The +/// snapshot is copied afterwards, in the same dispatched callback, and is never merged with the command result. +/// +/// The activation outcome CheatEngine.SDK reported. +/// +/// Why the command was not attempted, or the problem of an indeterminate command; +/// otherwise. +/// +/// +/// The record copied after the command, when the setter ran or was not needed and the copy succeeded. +/// +internal readonly record struct TableActivationObservation( + MemoryRecordActivationOutcomeKind Kind, + MemoryRecordMutationProblem Problem, + MemoryRecordSnapshot? Snapshot) +{ + /// Creates an observation without a record snapshot. + /// The activation outcome. + /// The problem CheatEngine.SDK reported with it. + /// The observation. + internal static TableActivationObservation Of(MemoryRecordActivationOutcomeKind kind, + MemoryRecordMutationProblem problem = MemoryRecordMutationProblem.None) + { + return new TableActivationObservation(kind, problem, null); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs index 0e055aa..590affb 100644 --- a/libs/CheatEngine.Client.Core/Domains/TableClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -7,86 +7,114 @@ using CheatEngine.SDK.Engine.AddressList; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Marshalling; namespace CheatEngine.Client.Core.Domains; +/// Address List domain: copied snapshots, host-visible mutations and trusted table files. +/// +/// Record identifiers are bound to the table load in which this activation observed them +/// (): every identifier-taking operation refuses an identifier captured before the +/// last trusted table load. The check runs before dispatch and again inside the dispatched callback on Cheat Engine's +/// main thread, where trusted loads advance the generation, so an operation queued behind an in-flight load is +/// judged against the table that load produced. Every copied snapshot is observed with the generation it was copied +/// in, read inside the same dispatched callback. +/// internal sealed class TableClient( ICheatEngineDispatcher dispatcher, CoreClientPolicy policy, ITableRecordMutationPort? recordMutations = null, CoreLifetime? lifetime = null, - ITableRecordLookupPort? recordLookups = null) : ITableClient + ITableRecordLookupPort? recordLookups = null, + ITableFilePort? tableFiles = null, + ITableHierarchyPort? hierarchy = null) : ITableClient { - private const string _getHierarchyOperation = "Tables.GetHierarchy"; + /// The message of a refused identifier captured before the last trusted table load. + internal const string StaleRecordIdentifierMessage = + "The memory record identifier was captured before the last trusted table load of this activation; read the " + + "record again."; + + private const string GetHierarchyOperation = "Tables.GetHierarchy"; private readonly ICheatEngineDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + private readonly TableRecordGeneration _generation = new(); + private readonly CoreLifetime? _lifetime = lifetime; private readonly CoreClientPolicy _policy = policy ?? throw new ArgumentNullException(nameof(policy)); private readonly ITableRecordLookupPort _recordLookups = recordLookups ?? new SdkTableRecordLookupPort(); private readonly ITableRecordMutationPort _recordMutations = recordMutations ?? new SdkTableRecordMutationPort(); + private readonly ITableFilePort _tableFiles = tableFiles ?? SdkTableFilePort.Instance; + private readonly ITableHierarchyPort _hierarchy = hierarchy ?? new SdkTableRecordLookupPort(); + + // Depth of the trusted table loads in progress. Read and written only inside dispatched callbacks, on Cheat Engine's + // main thread, where every load runs: a callback that observes a non-zero depth runs inside a load, like a script of + // the table being loaded calling the Client. + private int _trustedLoadDepth; + + /// Gets the number of trusted table loads of this activation that reached Cheat Engine. + internal long TableGeneration => _generation.Generation; - public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure failure, + /// + /// Gets the refusal of a creation, update or selection issued while a trusted table load runs: CheatEngine.SDK + /// refuses its own Address List commands then (the identifiers they resolve are being replaced), and has no + /// command for these three, so the Client refuses them the same way before any Cheat Engine call. + /// + private static TableRecordMutationOutcome LoadInProgress => + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.TableLoadInProgress); + + /// Gets whether a trusted table load is running; read only inside a dispatched callback. + private bool IsTrustedLoadInProgress => _trustedLoadDepth != 0; + + public bool TryGetRecordCount(out int recordCount, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - AddressTableSnapshot captured = default; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && - CaptureTable(count, out captured), out failure, cancellationToken)) + const string Operation = "Tables.GetRecordCount"; + ThrowIfDispatchRefused(Operation); + int count = 0; + bool available = false; + bool counted = false; + if (!SdkBoundary.TryInvoke(_dispatcher, Operation, () => + { + available = AddressListAccess.TryGetCurrent(out AddressList list); + counted = available && list.TryGetCount(out count) && count >= 0; + }, CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) { - table = default; + recordCount = 0; return false; } - table = captured; - if (succeeded) + recordCount = counted ? count : 0; + if (counted) { return true; } - failure = HostFailure("Tables.GetCurrent"); + failure = LookupFailure(Operation, + available ? RecordLookupStatus.InvalidRecord : RecordLookupStatus.AddressListUnavailable); return false; } - public AddressTableSnapshot GetCurrent(CancellationToken cancellationToken = default) + public int GetRecordCount(CancellationToken cancellationToken = default) { - if (TryGetCurrent(out AddressTableSnapshot result, out CheatEngineFailure failure, cancellationToken)) + if (TryGetRecordCount(out int result, out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); - return default; + failure.Throw(cancellationToken); + return 0; } public bool TryGetSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - AddressTableSnapshot captured = default; - bool succeeded = false; - bool exceededLimit = false; - if (!_dispatcher.TryInvoke( - () => succeeded = TryCaptureSnapshot(request, out captured, out exceededLimit), - out failure, cancellationToken)) - { - table = default; - return false; - } - - table = captured; - if (succeeded) - { - return true; - } - - failure = exceededLimit - ? ResultLimitFailure("Tables.GetSnapshot", request.MaximumItems) - : HostFailure("Tables.GetSnapshot"); - return false; + ValidateCollectionRequest(request); + ThrowIfDispatchRefused("Tables.GetSnapshot"); + return TryCopyTopLevel("Tables.GetSnapshot", request, out table, out failure, cancellationToken); } public AddressTableSnapshot GetSnapshot(MemoryRecordCollectionRequest request, @@ -97,7 +125,7 @@ public AddressTableSnapshot GetSnapshot(MemoryRecordCollectionRequest request, return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -105,15 +133,11 @@ public bool TryFind(MemoryRecordSearch search, MemoryRecordCollectionRequest req out ImmutableArray records, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - if (!HasPredicate(search)) - { - records = []; - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Tables.Find", - "A memory-record search must specify at least one predicate."); - return false; - } - - if (!TryGetSnapshot(request, out AddressTableSnapshot snapshot, out failure, cancellationToken)) + ValidateSearch(search); + ValidateCollectionRequest(request); + ThrowIfDispatchRefused("Tables.Find"); + if (!TryCopyTopLevel("Tables.Find", request, out AddressTableSnapshot snapshot, out failure, + cancellationToken)) { records = []; return false; @@ -121,8 +145,10 @@ public bool TryFind(MemoryRecordSearch search, MemoryRecordCollectionRequest req if (cancellationToken.IsCancellationRequested) { + // The read-only snapshot has already been copied: the host call completed and left nothing behind. records = []; - failure = CoreFailureFactory.Cancelled("Tables.Find"); + failure = CancellationMapping.AfterNativeCall("Tables.Find", + "The search was cancelled after the Address List snapshot was copied; no result was published."); return false; } @@ -135,38 +161,46 @@ public ImmutableArray Find(MemoryRecordSearch search, MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default) { if (TryFind(search, request, out ImmutableArray result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return []; } - public bool TryGetRecord(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + public bool TryGetRecordAt(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegative(index); - return TryRecord("Tables.GetRecord", (out result) => + ThrowIfDispatchRefused("Tables.GetRecordAt"); + return TryRecord("Tables.GetRecordAt", null, (out result) => _recordLookups.TryGetRecord(index, out result), out record, out failure, cancellationToken); } public bool TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryRecord("Tables.GetRecord", (out result) => + ThrowIfDispatchRefused("Tables.GetRecord"); + if (IsStale("Tables.GetRecord", id, out failure)) + { + record = default; + return false; + } + + return TryRecord("Tables.GetRecord", id, (out result) => _recordLookups.TryGetRecord(id, out result), out record, out failure, cancellationToken); } - public MemoryRecordSnapshot GetRecord(int index, CancellationToken cancellationToken = default) + public MemoryRecordSnapshot GetRecordAt(int index, CancellationToken cancellationToken = default) { - if (TryGetRecord(index, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + if (TryGetRecordAt(index, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -177,69 +211,105 @@ public MemoryRecordSnapshot GetRecord(MemoryRecordId id, CancellationToken cance return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } - public bool TryGetSelected(out MemoryRecordSnapshot record, out CheatEngineFailure failure, + public bool TryGetSelectedRecord(out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryRecord("Tables.GetSelected", _recordLookups.TryGetSelected, out record, out failure, + ThrowIfDispatchRefused("Tables.GetSelectedRecord"); + return TryRecord("Tables.GetSelectedRecord", null, _recordLookups.TryGetSelected, out record, out failure, cancellationToken); } - public MemoryRecordSnapshot GetSelected(CancellationToken cancellationToken = default) + public MemoryRecordSnapshot GetSelectedRecord(CancellationToken cancellationToken = default) { - if (TryGetSelected(out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + if (TryGetSelectedRecord(out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } - public bool TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + public bool TrySelectRecord(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryMutateRecord("Tables.Select", id, - static (list, value) => list.TrySetSelectedRecord(value), out record, out failure, cancellationToken); + ThrowIfDispatchRefused("Tables.SelectRecord"); + if (IsStale("Tables.SelectRecord", id, out failure)) + { + record = default; + return false; + } + + // Changing Cheat Engine's GUI selection is a host-visible mutation, so it goes through the mutation port. + MemoryRecordSnapshot captured = default; + TableRecordMutationOutcome outcome = default; + if (!TryDispatch("Tables.SelectRecord", id, null, + () => outcome = IsTrustedLoadInProgress ? LoadInProgress : _recordMutations.TrySelect(id, out captured), + out long observedGeneration, out failure, cancellationToken)) + { + record = default; + return false; + } + + if (outcome.IsSuccess) + { + _generation.Observe(captured, observedGeneration); + record = captured; + return true; + } + + record = default; + failure = TableMapping.MutationFailure("Tables.SelectRecord", outcome); + return false; } public MemoryRecordSnapshot SelectRecord(MemoryRecordId id, CancellationToken cancellationToken = default) { - if (TrySelect(id, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + if (TrySelectRecord(id, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } public bool TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateDefinition(definition); + ThrowIfDispatchRefused("Tables.Create"); + if (definition.ParentId is { } parentId && IsStale("Tables.Create", parentId, out failure)) + { + record = default; + return false; + } + MemoryRecordSnapshot captured = default; - bool succeeded = false; - TableRecordMutationStatus parentMutationStatus = TableRecordMutationStatus.Success; - if (!_dispatcher.TryInvoke( - () => succeeded = TryCreateRecord(definition, out captured, out parentMutationStatus), - out failure, cancellationToken)) + TableRecordCreation creation = default; + if (!TryDispatch("Tables.Create", definition.ParentId, null, + () => creation = IsTrustedLoadInProgress + ? new TableRecordCreation(LoadInProgress, TableRecordRollback.NotRequired) + : _recordMutations.TryCreate(definition, out captured), + out long observedGeneration, out failure, cancellationToken)) { record = default; return false; } - record = captured; - if (succeeded) + if (creation.Outcome.IsSuccess) { + _generation.Observe(captured, observedGeneration); + record = captured; return true; } - failure = parentMutationStatus == TableRecordMutationStatus.Success - ? HostFailure("Tables.Create") - : MutationFailure("Tables.Create", parentMutationStatus); + record = default; + failure = CreateCreationFailure(creation); return false; } @@ -251,25 +321,46 @@ public MemoryRecordSnapshot Create(MemoryRecordDefinition definition, return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } - public bool TryUpdate(MemoryRecordUpdate update, out MemoryRecordSnapshot record, + public bool TryUpdate(MemoryRecordId id, MemoryRecordUpdate update, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateUpdate(update); + ThrowIfDispatchRefused("Tables.Update"); + if (IsStale("Tables.Update", id, out failure)) + { + record = default; + return false; + } + MemoryRecordSnapshot captured = default; bool succeeded = false; - if (!_dispatcher.TryInvoke(() => succeeded = TryUpdateRecord(update, out captured), out failure, - cancellationToken)) + bool refused = false; + if (!TryDispatch("Tables.Update", id, null, () => + { + refused = IsTrustedLoadInProgress; + succeeded = !refused && TryUpdateRecord(id, update, out captured); + }, + out long observedGeneration, out failure, cancellationToken)) { record = default; return false; } + if (refused) + { + record = default; + failure = TableMapping.MutationFailure("Tables.Update", LoadInProgress); + return false; + } + record = captured; if (succeeded) { + _generation.Observe(captured, observedGeneration); return true; } @@ -277,33 +368,42 @@ record = captured; return false; } - public MemoryRecordSnapshot Update(MemoryRecordUpdate update, CancellationToken cancellationToken = default) + public MemoryRecordSnapshot Update(MemoryRecordId id, MemoryRecordUpdate update, + CancellationToken cancellationToken = default) { - if (TryUpdate(update, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + if (TryUpdate(id, update, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } public bool TryDelete(MemoryRecordId id, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; - if (!_dispatcher.TryInvoke(() => status = _recordMutations.TryDelete(id), out failure, cancellationToken)) + ThrowIfDispatchRefused("Tables.Delete"); + if (IsStale("Tables.Delete", id, out failure)) { return false; } - if (status == TableRecordMutationStatus.Success) + // A successful delete does not make the identifier stale: a second delete reports not found (A14-38). + TableRecordMutationOutcome outcome = default; + if (!TryDispatch("Tables.Delete", id, null, () => outcome = _recordMutations.TryDelete(id), out _, + out failure, cancellationToken)) + { + return false; + } + + if (outcome.IsSuccess) { failure = default; return true; } - failure = MutationFailure("Tables.Delete", status); + failure = TableMapping.MutationFailure("Tables.Delete", outcome); return false; } @@ -311,58 +411,97 @@ public void Delete(MemoryRecordId id, CancellationToken cancellationToken = defa { if (!TryDelete(id, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } public bool TrySetActive(MemoryRecordId id, bool isActive, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryMutateRecord("Tables.SetActive", id, - static (_, value, active) => value.Handle.TrySetProperty("Active"u8, active), - isActive, out record, out failure, cancellationToken); + const string Operation = "Tables.SetActive"; + ThrowIfDispatchRefused(Operation); + if (IsStale(Operation, id, out failure)) + { + record = default; + return false; + } + + TableActivationObservation observation = default; + if (!TryDispatch(Operation, id, null, () => observation = _recordMutations.TrySetActive(id, isActive), + out long observedGeneration, out failure, cancellationToken)) + { + record = default; + return false; + } + + if (observation.Snapshot is { } snapshot) + { + _generation.Observe(snapshot, observedGeneration); + } + + bool applied = TableMapping.TryClassifyActivation(Operation, isActive, observation, out failure); + record = TableMapping.CopiesRecord(observation.Kind) ? observation.Snapshot ?? default : default; + if (GetNotAppliedStatus(observation.Kind) is { } notApplied) + { + _lifetime?.Diagnostics.RecordActivationNotApplied(Operation, isActive, notApplied); + } + + return applied; } public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, CancellationToken cancellationToken = default) { if (TrySetActive(id, isActive, out MemoryRecordSnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } public bool TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ThrowIfDispatchRefused("Tables.SetParent"); + if (IsStale("Tables.SetParent", childId, out failure) || + (parentId is { } parentToCheck && IsStale("Tables.SetParent", parentToCheck, out failure))) + { + record = default; + return false; + } + if (parentId is { } requestedParentId && requestedParentId == childId) { + // CheatEngine.SDK refuses it the same way before any Lua call; refusing it here spares the dispatch. record = default; - failure = MutationFailure("Tables.SetParent", TableRecordMutationStatus.InvalidRelationship); + failure = TableMapping.MutationFailure("Tables.SetParent", + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.SelfParent)); return false; } MemoryRecordSnapshot captured = default; - TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; - if (!_dispatcher.TryInvoke(() => status = _recordMutations.TrySetParent(childId, parentId, out captured), - out failure, cancellationToken)) + TableRecordMutationOutcome outcome = default; + if (!TryDispatch("Tables.SetParent", childId, parentId, + () => outcome = _recordMutations.TrySetParent(childId, parentId, out captured), + out long observedGeneration, out failure, cancellationToken)) { record = default; return false; } - record = captured; - if (status == TableRecordMutationStatus.Success) + if (outcome.IsSuccess) { + _generation.Observe(captured, observedGeneration); + record = captured; failure = default; return true; } - failure = MutationFailure("Tables.SetParent", status); + record = default; + failure = TableMapping.MutationFailure("Tables.SetParent", outcome); return false; } @@ -370,12 +509,12 @@ public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? pa CancellationToken cancellationToken = default) { if (TrySetParent(childId, parentId, out MemoryRecordSnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } @@ -383,23 +522,39 @@ public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + if (request.MaximumItems <= 0 || request.MaximumDepth <= 0) + { + // Only the default request allows no record and no level: its constructor throws for both. + throw new ArgumentOutOfRangeException(nameof(request), + "A memory-record hierarchy request must allow at least one record and one level; the default request " + + "allows none."); + } + + ThrowIfDispatchRefused(GetHierarchyOperation); + if (IsStale(GetHierarchyOperation, rootId, out failure)) + { + hierarchy = default; + return false; + } + MemoryRecordHierarchySnapshot captured = default; bool found = false; bool succeeded = false; - HierarchyBuildProblem problem = HierarchyBuildProblem.None; - if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) - { - return; - } - - found = true; - HashSet visited = []; - int materialized = 0; - succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); - }, out failure, cancellationToken)) + HierarchyProblem problem = default; + RecordLookupStatus rootStatus = RecordLookupStatus.Success; + if (!TryDispatch(GetHierarchyOperation, rootId, null, () => + { + rootStatus = _hierarchy.TryGetRoot(rootId, out ITableHierarchyRecord? root); + if (rootStatus != RecordLookupStatus.Success || root is null) + { + return; + } + + found = true; + HashSet visited = []; + int materialized = 0; + succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); + }, out long observedGeneration, out failure, cancellationToken)) { hierarchy = default; return false; @@ -408,10 +563,15 @@ public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest hierarchy = captured; if (succeeded) { + _generation.Observe(captured, observedGeneration); return true; } - failure = GetHierarchyFailure(problem, found, request); + // A root lookup that failed is reported like every other record lookup: an unavailable Address List is + // CapabilityUnavailable, an absent record NotFound and a malformed one InvalidHostResult. + failure = rootStatus == RecordLookupStatus.Success + ? GetHierarchyFailure(problem, found, request) + : LookupFailure(GetHierarchyOperation, rootStatus); return false; } @@ -419,63 +579,107 @@ public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, CancellationToken cancellationToken = default) { if (TryGetHierarchy(rootId, request, out MemoryRecordHierarchySnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default; } public bool TryLoadTrustedTable(TableLoadRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateFile(request.File, nameof(request)); _lifetime?.ThrowIfInactive("Tables.LoadTrustedTable"); if (!TryAuthorize(request.File.FullPath, "Tables.LoadTrustedTable", out failure)) { return false; } - return _dispatcher.TryInvoke(() => ClientLuaGlobals.LoadTable(request.File.FullPath, request.Merge), - out failure, cancellationToken); + // CheatTableFiles.TryLoad calls loadTable, which can execute table Lua: a failure or a fault leaves the Address + // List state unknown. A dispatched load advances the table generation whatever its result, merge or replace: + // Cheat Engine does not promise that an earlier record identifier survives it (A14-05, open issue O4). The + // generation advances inside the dispatched callback, on Cheat Engine's main thread, so it is ordered with every + // dispatched snapshot copy and identifier check. The diagnostics event is emitted after dispatch, never inside the + // callback. The refused path above never reaches here and is never retried through another overload or a stream. + bool reachedCheatEngine = false; + long advancedGeneration = 0; + LuaOperationStatus status = default; + try + { + if (!SdkBoundary.TryInvoke(_dispatcher, "Tables.LoadTrustedTable", + () => + { + reachedCheatEngine = true; + _trustedLoadDepth++; + try + { + status = _tableFiles.TryLoad(request.File.FullPath, request.Merge); + } + finally + { + _trustedLoadDepth--; + advancedGeneration = _generation.Advance(); + } + }, + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) + { + return false; + } + } + finally + { + if (reachedCheatEngine) + { + _lifetime?.Diagnostics.TableGenerationAdvanced(_lifetime.Epoch, advancedGeneration); + } + } + + return TableMapping.TryClassifyTableFile("Tables.LoadTrustedTable", true, status.Kind, out failure); } public void LoadTrustedTable(TableLoadRequest request, CancellationToken cancellationToken = default) { if (!TryLoadTrustedTable(request, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } public bool TrySaveTable(TableSaveRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + ValidateFile(request.File, nameof(request)); _lifetime?.ThrowIfInactive("Tables.SaveTable"); if (!TryAuthorize(request.File.FullPath, "Tables.SaveTable", out failure)) { return false; } - return _dispatcher.TryInvoke(() => ClientLuaGlobals.SaveTable(request.File.FullPath), - out failure, cancellationToken); + LuaOperationStatus status = default; + return SdkBoundary.TryInvoke(_dispatcher, "Tables.SaveTable", + () => status = _tableFiles.TrySave(request.File.FullPath), CheatEngineHostEffect.Unknown, _lifetime, + out failure, cancellationToken) && + TableMapping.TryClassifyTableFile("Tables.SaveTable", false, status.Kind, out failure); } public void SaveTable(TableSaveRequest request, CancellationToken cancellationToken = default) { if (!TrySaveTable(request, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } - private bool TryRecord(string operation, RecordLookup lookup, + private bool TryRecord(string operation, MemoryRecordId? id, RecordLookup lookup, out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken) { MemoryRecordSnapshot captured = default; RecordLookupStatus status = RecordLookupStatus.InvalidRecord; - if (!_dispatcher.TryInvoke(() => status = lookup(out captured), out failure, cancellationToken)) + if (!TryDispatch(operation, id, null, () => status = lookup(out captured), out long observedGeneration, + out failure, cancellationToken)) { record = default; return false; @@ -484,6 +688,7 @@ record = default; record = captured; if (status == RecordLookupStatus.Success) { + _generation.Observe(captured, observedGeneration); return true; } @@ -491,99 +696,263 @@ record = captured; return false; } - private static bool TryCaptureSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, - out bool exceededLimit) + /// + /// Copies every top-level record under the caller's validated limit for and + /// , each failure named after the public operation that ran it. + /// + /// + /// A copy that exceeds the limit is ; any other status + /// is reported like every record lookup (), so an unavailable Address List is + /// here too. + /// + private bool TryCopyTopLevel(string operation, MemoryRecordCollectionRequest request, + out AddressTableSnapshot table, out CheatEngineFailure failure, CancellationToken cancellationToken) { - table = default; - exceededLimit = false; - if (!AddressListAccess.TryGetCurrent(out AddressList list) || !list.TryGetCount(out int count)) + AddressTableSnapshot captured = default; + RecordLookupStatus status = RecordLookupStatus.InvalidRecord; + if (!TryDispatch(operation, null, null, + () => status = _recordLookups.TryGetTable(request.MaximumItems, out captured), + out long observedGeneration, out failure, cancellationToken)) { + table = default; return false; } - if (count > request.MaximumItems) + if (status == RecordLookupStatus.Success) { - exceededLimit = true; - return false; + _generation.Observe(captured, observedGeneration); + table = captured; + return true; } - return TryCaptureRecords(list, count, out table); + table = default; + failure = status == RecordLookupStatus.LimitExceeded + ? ResultLimitFailure(operation, request.MaximumItems) + : LookupFailure(operation, status); + return false; } - private static bool TryCaptureRecords(AddressList list, int count, out AddressTableSnapshot table) + /// + /// Dispatches Client-internal Address List work after re-checking, on Cheat Engine's main thread, that no + /// identifier it takes became stale, and reads there the table generation the work observes. + /// + /// + /// A trusted load dispatched after the pre-dispatch check and before this callback advances the generation on the + /// main thread first; the re-check then refuses the identifier without calling Cheat Engine. The generation is read + /// before the work, so a snapshot copied while a nested load ran inside the work is treated as an earlier state. + /// + private bool TryDispatch(string operation, MemoryRecordId? firstId, MemoryRecordId? secondId, Action work, + out long observedGeneration, out CheatEngineFailure failure, CancellationToken cancellationToken) { - ImmutableArray.Builder records = - ImmutableArray.CreateBuilder(count); - for (int index = 0; index < count; index++) - { - if (!list.TryGetMemoryRecord(index, out MemoryRecord value) || - !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) + bool stale = false; + long generation = 0; + bool dispatched = SdkBoundary.TryInvoke(_dispatcher, operation, () => { - table = default; - return false; - } + if ((firstId is { } first && _generation.IsStale(first)) || + (secondId is { } second && _generation.IsStale(second))) + { + stale = true; + return; + } + + generation = _generation.Generation; + work(); + }, + CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken); + observedGeneration = generation; + if (!dispatched) + { + return false; + } - records.Add(snapshot); + if (stale) + { + failure = RefuseStale(operation); + return false; } - table = new AddressTableSnapshot(records.MoveToImmutable()); return true; } - private bool TryCreateRecord(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, - out TableRecordMutationStatus parentMutationStatus) + /// + /// Throws when the activation refuses dispatch, after the arguments were validated and before the refusals + /// decided without Cheat Engine (a stale identifier, a self-parent): an ended or stopping activation throws + /// before a refusal is reported, never the reverse. + /// + private void ThrowIfDispatchRefused(string operation) { - record = default; - parentMutationStatus = TableRecordMutationStatus.Success; - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryCreateMemoryRecord(out MemoryRecord value)) + _lifetime?.ThrowIfDispatchRefused(operation); + } + + /// + /// Throws for the default collection request, which allows no record, as its constructor throws for a limit + /// below one. + /// + /// The request allows no record. + private static void ValidateCollectionRequest(MemoryRecordCollectionRequest request) + { + if (request.MaximumItems <= 0) { - return false; + throw new ArgumentOutOfRangeException(nameof(request), request.MaximumItems, + "A memory-record request must allow at least one record; the default request allows none."); } + } - bool succeeded = TryInitializeRecord(value, definition) && - TryCompleteRecordCreation(value, definition, out record, out parentMutationStatus); - if (!succeeded) + /// Throws for a search its constructor would refuse: the default search, which has no predicate. + /// The search has no predicate, or an empty text predicate. + /// Its value type is not a defined value. + private static void ValidateSearch(MemoryRecordSearch search) + { + if (!HasPredicate(search)) + { + throw new ArgumentException( + "A memory-record search must specify at least one predicate; the default search has none.", + nameof(search)); + } + + if (search.DescriptionContains is { Length: 0 } || search.AddressExpression is { Length: 0 }) { - _ = value.Handle.TryCallMethod("destroy"u8); + throw new ArgumentException("A memory-record search text must be null or non-empty.", nameof(search)); } - return succeeded; + if (search.VariableType is { } variableType && !Enum.IsDefined(variableType)) + { + throw new ArgumentOutOfRangeException(nameof(search), variableType, + "A memory-record search compares a defined value type."); + } } - private static bool TryInitializeRecord(MemoryRecord value, MemoryRecordDefinition definition) + /// + /// Throws for a definition its constructor would refuse: the default definition, which has no field. + /// + /// The definition has no description, address expression or value. + /// Its value type is not a defined value. + private static void ValidateDefinition(MemoryRecordDefinition definition) { - return value.TrySetDescription(definition.Description) && - value.TrySetAddressExpression(definition.AddressExpression) && - value.TrySetVariableType(definition.VariableType) && - value.TrySetValue(definition.Value); + if (definition.Description is null || string.IsNullOrWhiteSpace(definition.AddressExpression) || + definition.Value is null) + { + throw new ArgumentException( + "A memory-record definition requires a description, an address expression and a value; the default " + + "definition has none.", nameof(definition)); + } + + if (!Enum.IsDefined(definition.VariableType)) + { + throw new ArgumentOutOfRangeException(nameof(definition), definition.VariableType, + "A memory-record definition assigns a defined value type."); + } } - private bool TryCompleteRecordCreation(MemoryRecord value, MemoryRecordDefinition definition, - out MemoryRecordSnapshot record, out TableRecordMutationStatus parentMutationStatus) + /// Throws for an update its constructor would refuse: the default update, which changes nothing. + /// The update changes nothing, or sets an empty address expression. + /// Its value type is not a defined value. + private static void ValidateUpdate(MemoryRecordUpdate update) { - record = default; - parentMutationStatus = TableRecordMutationStatus.Success; - if (definition.ParentId is not { } parentId) + if (update.Description is null && update.AddressExpression is null && update.Value is null && + update.VariableType is null) + { + throw new ArgumentException( + "A memory-record update must change at least one field; the default update changes none.", + nameof(update)); + } + + if (update.AddressExpression is { Length: 0 }) + { + throw new ArgumentException("An address expression must be null or non-empty.", nameof(update)); + } + + if (update.VariableType is { } variableType && !Enum.IsDefined(variableType)) + { + throw new ArgumentOutOfRangeException(nameof(update), variableType, + "A memory-record update assigns a defined value type."); + } + } + + /// Throws for the default table file request, which names no file. + /// The file of the request. + /// The name of the request parameter of the public call. + /// The request names no file. + private static void ValidateFile(TrustedTableFile file, string parameterName) + { + if (string.IsNullOrWhiteSpace(file.FullPath)) { - return TrySnapshot(value, out record); + throw new ArgumentException( + "A table file request must name a trusted table file; the default request names none.", parameterName); } + } - if (!value.TryGetId(out MemoryRecordId createdId)) + /// Refuses, before any dispatch, an identifier captured before the last trusted table load. + private bool IsStale(string operation, MemoryRecordId id, out CheatEngineFailure failure) + { + if (!_generation.IsStale(id)) { - parentMutationStatus = TableRecordMutationStatus.HostRejected; + failure = default; return false; } - parentMutationStatus = _recordMutations.TrySetParent(createdId, parentId, out record); - return parentMutationStatus == TableRecordMutationStatus.Success; + failure = RefuseStale(operation); + return true; + } + + /// Creates the stale-identifier refusal and emits its diagnostics event (never inside a callback). + private CheatEngineFailure RefuseStale(string operation) + { + _lifetime?.Diagnostics.StaleRecordIdentifierRefused(operation, _generation.Generation); + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, StaleRecordIdentifierMessage, + null, CheatEngineHostEffect.NotStarted); + } + + /// + /// Names an activation outcome that did not apply the requested state for the diagnostics event, or returns + /// for an applied, unchanged or refused-before-start outcome. + /// + private static string? GetNotAppliedStatus(MemoryRecordActivationOutcomeKind kind) + { + return kind switch + { + MemoryRecordActivationOutcomeKind.Applied or MemoryRecordActivationOutcomeKind.Unchanged + or MemoryRecordActivationOutcomeKind.NotAttempted => null, + MemoryRecordActivationOutcomeKind.RefusedByHost => nameof(MemoryRecordActivationOutcomeKind.RefusedByHost), + MemoryRecordActivationOutcomeKind.Pending => nameof(MemoryRecordActivationOutcomeKind.Pending), + _ => nameof(MemoryRecordActivationOutcomeKind.Indeterminate) + }; } - private static bool TryUpdateRecord(MemoryRecordUpdate update, out MemoryRecordSnapshot record) + /// Classifies a failed creation and states whether a partially initialized record may remain. + private CheatEngineFailure CreateCreationFailure(TableRecordCreation creation) + { + CheatEngineFailure failure = creation.Fault is { } fault + ? SdkBoundary.Translate("Tables.Create", fault, CheatEngineHostEffect.Unknown, _lifetime) + : TableMapping.MutationFailure("Tables.Create", creation.Outcome); + return creation.Rollback switch + { + TableRecordRollback.Confirmed => + CoreFailureFactory.WithHostEffect(failure, CheatEngineHostEffect.Completed), + TableRecordRollback.Unconfirmed => new CheatEngineFailure(failure.Kind, failure.Operation, + failure.Message + " The rollback of the partially initialized memory record was not confirmed, so " + + "it may remain in the Address List.", + CombineFaults(failure.Exception, creation.RollbackFault), CheatEngineHostEffect.CleanupUnconfirmed), + _ => failure + }; + } + + private static Exception? CombineFaults(Exception? primary, Exception? rollback) + { + return (primary, rollback) switch + { + ({ } first, { } second) => new AggregateException(first, second), + ({ } first, null) => first, + (null, { } second) => second, + _ => null + }; + } + + private static bool TryUpdateRecord(MemoryRecordId id, MemoryRecordUpdate update, out MemoryRecordSnapshot record) { record = default; if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) + !list.TryGetMemoryRecordById(id, out MemoryRecord value)) { return false; } @@ -594,9 +963,9 @@ record = default; private static bool TryApplyUpdate(MemoryRecord value, MemoryRecordUpdate update) { return (update.Description is null || value.TrySetDescription(update.Description)) && - (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && - (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && - (update.Value is null || value.TrySetValue(update.Value)); + (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && + (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && + (update.Value is null || value.TrySetValue(update.Value)); } private bool TryAuthorize(string path, string operation, out CheatEngineFailure failure) @@ -604,7 +973,8 @@ private bool TryAuthorize(string path, string operation, out CheatEngineFailure if (_policy.AllowedTableRoots.Count == 0) { failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, - "Table import and export are disabled because no allowed root is configured."); + "Table import and export are disabled because no allowed root is configured.", null, + CheatEngineHostEffect.NotStarted); return false; } @@ -614,125 +984,106 @@ private bool TryAuthorize(string path, string operation, out CheatEngineFailure return true; } - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, - reason); + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, reason, null, + CheatEngineHostEffect.NotStarted); return false; } + /// Copies one record through CheatEngine.SDK's typed getters. + /// + /// Every field is required except the current address, which Cheat Engine cannot always resolve, and the script, + /// which only an Auto Assembler record has: reports no script text for + /// any other record. It reports a failed read the same way (a Lua error or a non-string result), so a missing + /// script is copied as and never fails the snapshot, which the snapshot documents. Telling + /// the two apart needs a CheatEngine.SDK getter that distinguishes them, like the child count below. + /// internal static bool TrySnapshot(MemoryRecord value, out MemoryRecordSnapshot snapshot) { if (!value.TryGetId(out MemoryRecordId id) || !value.TryGetIndex(out int index) || - !value.TryGetDescription(out string? description) || - !value.TryGetAddressExpression(out string? expression) || - !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || - !value.Handle.TryGetProperty("Active"u8, out bool isActive) || - !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) + !value.TryGetDescription(out string? description) || + !value.TryGetAddressExpression(out string? expression) || + !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || + !value.TryGetOffsetCount(out int offsetCount) || offsetCount < 0 || + !value.TryGetActive(out bool isActive) || !value.TryGetAsync(out bool isAsync) || + !value.TryGetAsyncProcessing(out bool isAsyncProcessing) || !TryGetChildCount(value, out int childCount)) { snapshot = default; return false; } + string? script = value.TryGetScript(out string? scriptText) ? scriptText : null; Address? currentAddress = value.TryGetCurrentAddress(out Address address) ? address : null; snapshot = new MemoryRecordSnapshot( id, index, - new MemoryRecordContentSnapshot(description, expression, text, variableType), - new MemoryRecordStateSnapshot(currentAddress, isActive, childCount)); + new MemoryRecordContentSnapshot(description, expression, text, variableType, script, offsetCount), + new MemoryRecordStateSnapshot(currentAddress, isActive, childCount, isAsync, isAsyncProcessing)); return true; } - private bool TryMutateRecord(string operation, MemoryRecordId id, - Func mutation, out MemoryRecordSnapshot record, - out CheatEngineFailure failure, CancellationToken cancellationToken) + /// Reads Cheat Engine's Count property of a record: the number of its immediate children. + /// + /// CheatEngine.SDK 2.0.0 has no child-count getter, and reports an index + /// past the last child and a failed read alike, so keeps this + /// read for its precision (ADR-08). It is the one registered raw property read of the Tables domain in the ADR-01 + /// ratchet, until the SDK offers the getter. + /// + private static bool TryGetChildCount(MemoryRecord value, out int childCount) { - ArgumentNullException.ThrowIfNull(mutation); - MemoryRecordSnapshot captured = default; - bool found = false; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(id, out MemoryRecord value)) - { - return; - } - - found = true; - succeeded = mutation(list, value) && TrySnapshot(value, out captured); - }, out failure, cancellationToken)) - { - record = default; - return false; - } - - record = captured; - if (succeeded) - { - return true; - } - - failure = found - ? HostFailure(operation) - : new CheatEngineFailure(CheatEngineFailureKind.NotFound, operation, - "The requested Cheat Engine memory record was not found."); - return false; - } - - private bool TryMutateRecord(string operation, MemoryRecordId id, - Func mutation, TArgument argument, - out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(mutation); - return TryMutateRecord(operation, id, (list, value) => mutation(list, value, argument), out record, - out failure, cancellationToken); + return value.Handle.TryGetProperty("Count"u8, out childCount) && childCount >= 0; } - private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyRequest request, int depth, + private static bool TryBuildHierarchy(ITableHierarchyRecord value, MemoryRecordHierarchyRequest request, int depth, HashSet visited, ref int materialized, out MemoryRecordHierarchySnapshot hierarchy, - out HierarchyBuildProblem problem) + out HierarchyProblem problem) { hierarchy = default; if (materialized >= request.MaximumItems) { - problem = HierarchyBuildProblem.ItemLimit; + problem = new HierarchyProblem(HierarchyBuildProblem.ItemLimit); return false; } - if (!TrySnapshot(value, out MemoryRecordSnapshot snapshot) || !visited.Add(snapshot.Id)) + if (!value.TrySnapshot(out MemoryRecordSnapshot snapshot) || !visited.Add(snapshot.Id)) { - problem = HierarchyBuildProblem.InvalidShape; + problem = new HierarchyProblem(HierarchyBuildProblem.InvalidShape); return false; } materialized++; - if (snapshot.ChildCount == 0) + int childCount = snapshot.State.ChildCount; + if (childCount == 0) { hierarchy = new MemoryRecordHierarchySnapshot(snapshot, []); - problem = HierarchyBuildProblem.None; + problem = default; return true; } if (depth >= request.MaximumDepth) { - problem = HierarchyBuildProblem.DepthLimit; + problem = new HierarchyProblem(HierarchyBuildProblem.DepthLimit); return false; } - if (snapshot.ChildCount > request.MaximumItems - materialized) + if (childCount > request.MaximumItems - materialized) { - problem = HierarchyBuildProblem.ItemLimit; + problem = new HierarchyProblem(HierarchyBuildProblem.ItemLimit); return false; } ImmutableArray.Builder children = - ImmutableArray.CreateBuilder(snapshot.ChildCount); - for (int index = 0; index < snapshot.ChildCount; index++) - { - problem = HierarchyBuildProblem.InvalidShape; - if (!value.TryGetChild(index, out MemoryRecord child) || - !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, - out MemoryRecordHierarchySnapshot childSnapshot, - out problem)) + ImmutableArray.CreateBuilder(childCount); + for (int index = 0; index < childCount; index++) + { + // Every position below the reported count must hold a child: Cheat Engine refusing one is reported at it. + if (!value.TryGetChild(index, out ITableHierarchyRecord? child)) + { + problem = new HierarchyProblem(HierarchyBuildProblem.ChildUnavailable, snapshot.Id, index, childCount); + return false; + } + + if (!TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, + out MemoryRecordHierarchySnapshot childSnapshot, out problem)) { return false; } @@ -741,47 +1092,49 @@ private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyR } hierarchy = new MemoryRecordHierarchySnapshot(snapshot, children.MoveToImmutable()); - problem = HierarchyBuildProblem.None; + problem = default; return true; } private static bool Matches(MemoryRecordSearch search, MemoryRecordSnapshot record) { - return (search.DescriptionContains is null || record.Description.Contains(search.DescriptionContains, - StringComparison.OrdinalIgnoreCase)) && - (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, - StringComparison.OrdinalIgnoreCase)) && - (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && - (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); + return (search.DescriptionContains is null || record.Content.Description.Contains(search.DescriptionContains, + StringComparison.OrdinalIgnoreCase)) && + (search.AddressExpression is null || string.Equals(record.Content.AddressExpression, + search.AddressExpression, StringComparison.OrdinalIgnoreCase)) && + (!search.VariableType.HasValue || record.Content.VariableType == search.VariableType.Value) && + (!search.IsActive.HasValue || record.State.IsActive == search.IsActive.Value); } private static bool HasPredicate(MemoryRecordSearch search) { return search.DescriptionContains is not null || search.AddressExpression is not null || - search.VariableType.HasValue || search.IsActive.HasValue; - } - - private static bool CaptureTable(int count, out AddressTableSnapshot table) - { - table = new AddressTableSnapshot(count); - return true; + search.VariableType.HasValue || search.IsActive.HasValue; } private static CheatEngineFailure HostFailure(string operation) { return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, - "Cheat Engine did not return the expected Address List contract."); + TableMapping.InvalidContractMessage); } + /// Classifies a failed record lookup, the same way for every Tables read. + /// + /// An unavailable Address List is with + /// : the lookup never ran, as for a mutation that + /// CheatEngine.SDK refused for the same reason. An absent record is + /// and a malformed one + /// . + /// private static CheatEngineFailure LookupFailure(string operation, RecordLookupStatus status) { return status switch { RecordLookupStatus.NotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, operation, - "The requested Cheat Engine memory record was not found."), + TableMapping.RecordNotFoundMessage), RecordLookupStatus.AddressListUnavailable => new CheatEngineFailure( - CheatEngineFailureKind.CapabilityUnavailable, operation, - "Cheat Engine's Address List capability is unavailable."), + CheatEngineFailureKind.CapabilityUnavailable, operation, TableMapping.AddressListUnavailableMessage, + null, CheatEngineHostEffect.NotStarted), RecordLookupStatus.InvalidRecord => HostFailure(operation), _ => HostFailure(operation) }; @@ -793,35 +1146,32 @@ private static CheatEngineFailure ResultLimitFailure(string operation, int maxim $"The operation requires more records than the explicit limit of {maximumItems}."); } - private static CheatEngineFailure GetHierarchyFailure(HierarchyBuildProblem problem, bool found, - MemoryRecordHierarchyRequest request) + /// + /// Creates the failure of a hierarchy copy that stopped because Cheat Engine did not return a child at a position + /// below the record's reported child count. + /// + private static CheatEngineFailure ChildUnavailableFailure(MemoryRecordId recordId, int childIndex, int childCount) { - return problem switch - { - HierarchyBuildProblem.ItemLimit => ResultLimitFailure(_getHierarchyOperation, request.MaximumItems), - HierarchyBuildProblem.DepthLimit => new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, - _getHierarchyOperation, - $"The operation requires a hierarchy depth greater than the explicit limit of {request.MaximumDepth}."), - HierarchyBuildProblem.InvalidShape => HostFailure(_getHierarchyOperation), - _ when !found => new CheatEngineFailure(CheatEngineFailureKind.NotFound, _getHierarchyOperation, - "The requested Cheat Engine memory record was not found."), - _ => HostFailure(_getHierarchyOperation) - }; + return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, GetHierarchyOperation, + $"Cheat Engine did not return child {childIndex} of memory record {recordId.Value}, which reports " + + $"{childCount} children."); } - private static CheatEngineFailure MutationFailure(string operation, TableRecordMutationStatus status) + private static CheatEngineFailure GetHierarchyFailure(HierarchyProblem problem, bool found, + MemoryRecordHierarchyRequest request) { - return status switch + return problem.Kind switch { - TableRecordMutationStatus.RecordNotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, - operation, "The requested Cheat Engine memory record was not found."), - TableRecordMutationStatus.ParentNotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, - operation, "The requested parent Cheat Engine memory record was not found."), - TableRecordMutationStatus.InvalidRelationship => new CheatEngineFailure( - CheatEngineFailureKind.OperationRejected, operation, - "The requested parent relationship is invalid: it is self-referential, cyclic, or exceeds the " + - "supported hierarchy depth."), - _ => HostFailure(operation) + HierarchyBuildProblem.ItemLimit => ResultLimitFailure(GetHierarchyOperation, request.MaximumItems), + HierarchyBuildProblem.DepthLimit => new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, + GetHierarchyOperation, + $"The operation requires a hierarchy depth greater than the explicit limit of {request.MaximumDepth}."), + HierarchyBuildProblem.ChildUnavailable => + ChildUnavailableFailure(problem.RecordId, problem.ChildIndex, problem.ChildCount), + HierarchyBuildProblem.InvalidShape => HostFailure(GetHierarchyOperation), + _ when !found => new CheatEngineFailure(CheatEngineFailureKind.NotFound, GetHierarchyOperation, + TableMapping.RecordNotFoundMessage), + _ => HostFailure(GetHierarchyOperation) }; } @@ -830,8 +1180,18 @@ private enum HierarchyBuildProblem None, ItemLimit, DepthLimit, - InvalidShape + InvalidShape, + + /// Cheat Engine did not return a child at a position below the record's reported child count. + ChildUnavailable } + /// Why a hierarchy copy stopped, and for a missing child, where. + private readonly record struct HierarchyProblem( + HierarchyBuildProblem Kind, + MemoryRecordId RecordId = default, + int ChildIndex = 0, + int ChildCount = 0); + private delegate RecordLookupStatus RecordLookup(out MemoryRecordSnapshot record); } diff --git a/libs/CheatEngine.Client.Core/Domains/TableMapping.cs b/libs/CheatEngine.Client.Core/Domains/TableMapping.cs new file mode 100644 index 0000000..334c9a1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableMapping.cs @@ -0,0 +1,388 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Maps every outcome that CheatEngine.SDK 2.0.0 reports for Address List record mutations +/// (AddressListMutations.Delete, SetParent and SetActive) and for table files +/// (CheatTableFiles.TryLoad and TrySave) to the Client vocabulary, value by value. +/// +/// +/// A record mutation maps its to a failure kind: +/// +/// +/// SDK problem +/// Failure kind +/// +/// TableLoadInProgressInvalidState +/// RuntimeIdentityChangedRuntimeChanged +/// +/// CycleDetected, SelfParent +/// OperationRejected +/// +/// TraversalLimitReachedResultLimitExceeded +/// +/// ParentNotFound, RecordNotFound +/// NotFound +/// +/// LuaFailureLuaError +/// InvalidResultInvalidHostResult +/// +/// AddressListUnavailable, GlobalUnavailable +/// CapabilityUnavailable +/// +/// +/// +/// Uninitialized, None on a mutation that did not complete, or an undefined problem +/// +/// IndeterminateHostResult, with an Unknown host effect +/// +/// +/// +/// and its to the host effect: NotAttempted is +/// NotStarted, Completed is Completed (the command completed and the record could not be +/// copied afterwards), Indeterminate is Started (the command began and raised, so part of it may +/// persist; it is never retried), and an undefined effect is Unknown. +/// +/// An activation maps its : +/// +/// +/// SDK kind +/// Result +/// +/// +/// Applied, Unchanged +/// Success, with the record copied after the command +/// +/// +/// Pending +/// +/// Success, with the record copied after the command: the record activates asynchronously and its +/// snapshot reports IsAsyncProcessing +/// +/// +/// +/// RefusedByHost +/// +/// OperationRejected, Started: the setter ran and the record reads back its previous state; +/// CheatEngine.SDK states that the refusal may have applied part of its effects +/// +/// +/// +/// Indeterminate +/// +/// IndeterminateHostResult, Started: the setter ran and its effect is unknown +/// +/// +/// +/// NotAttempted +/// The kind of its problem (table above), NotStarted +/// +/// +/// Unknown or an undefined kind +/// IndeterminateHostResult, Unknown +/// +/// +/// A table file load or save maps its : +/// +/// +/// SDK status +/// Result +/// +/// SuccessSuccess +/// +/// GlobalUnavailable +/// CapabilityUnavailable, NotStarted: the global was not called +/// +/// +/// StackUnavailable +/// LuaError, NotStarted: the call could not begin +/// +/// +/// LuaFailure +/// +/// LuaError, Started: a load may have applied part of the table and of its scripts, a save +/// may have written part of the file +/// +/// +/// +/// +/// NilResult, InvalidResult, MissingResult, ResultCapacityExceeded +/// +/// +/// InvalidHostResult, Started: loadTable and saveTable declare no result +/// +/// +/// +/// Unknown or an undefined status +/// IndeterminateHostResult, Unknown +/// +/// +/// +/// Messages name the category only, never a record description, an address or a path. The mapping-totality +/// tests fail when the consumed SDK adds a value. +/// +/// +internal static class TableMapping +{ + /// The message of an unavailable Address List (a capability condition, ADR-08). + internal const string AddressListUnavailableMessage = "Cheat Engine's Address List capability is unavailable."; + + /// The message of an absent record. + internal const string RecordNotFoundMessage = "The requested Cheat Engine memory record was not found."; + + /// The message of a Cheat Engine result outside the typed Address List contract. + internal const string InvalidContractMessage = "Cheat Engine did not return the expected Address List contract."; + + /// Returns the failure kind of a record mutation that did not succeed. + /// The problem CheatEngine.SDK or the Client step reported. + /// + /// The failure kind; for an unrecognized value. + /// + internal static CheatEngineFailureKind ToFailureKind(MemoryRecordMutationProblem problem) + { + return problem switch + { + MemoryRecordMutationProblem.TableLoadInProgress => CheatEngineFailureKind.InvalidState, + MemoryRecordMutationProblem.RuntimeIdentityChanged => CheatEngineFailureKind.RuntimeChanged, + MemoryRecordMutationProblem.CycleDetected => CheatEngineFailureKind.OperationRejected, + MemoryRecordMutationProblem.SelfParent => CheatEngineFailureKind.OperationRejected, + MemoryRecordMutationProblem.TraversalLimitReached => CheatEngineFailureKind.ResultLimitExceeded, + MemoryRecordMutationProblem.ParentNotFound => CheatEngineFailureKind.NotFound, + MemoryRecordMutationProblem.RecordNotFound => CheatEngineFailureKind.NotFound, + MemoryRecordMutationProblem.LuaFailure => CheatEngineFailureKind.LuaError, + MemoryRecordMutationProblem.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + MemoryRecordMutationProblem.AddressListUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + MemoryRecordMutationProblem.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + MemoryRecordMutationProblem.None => CheatEngineFailureKind.IndeterminateHostResult, + MemoryRecordMutationProblem.Uninitialized => CheatEngineFailureKind.IndeterminateHostResult, + _ => CheatEngineFailureKind.IndeterminateHostResult + }; + } + + /// Returns the host effect of a record mutation that did not succeed. + /// How far the mutation progressed. + /// The host effect; for an unrecognized value. + internal static CheatEngineHostEffect ToHostEffect(MemoryRecordMutationEffect effect) + { + return effect switch + { + MemoryRecordMutationEffect.NotAttempted => CheatEngineHostEffect.NotStarted, + MemoryRecordMutationEffect.Completed => CheatEngineHostEffect.Completed, + MemoryRecordMutationEffect.Indeterminate => CheatEngineHostEffect.Started, + _ => CheatEngineHostEffect.Unknown + }; + } + + /// Creates the failure of a record mutation that did not succeed. + /// The public operation name. + /// The mutation outcome. + /// + /// The failure. An unrecognized problem is with an + /// effect, never an established one. + /// + internal static CheatEngineFailure MutationFailure(string operation, TableRecordMutationOutcome outcome) + { + CheatEngineFailureKind kind = ToFailureKind(outcome.Problem); + CheatEngineHostEffect effect = kind == CheatEngineFailureKind.IndeterminateHostResult + ? CheatEngineHostEffect.Unknown + : ToHostEffect(outcome.Effect); + return new CheatEngineFailure(kind, operation, Describe(outcome), null, effect); + } + + /// Returns the failure kind and host effect of an activation, or for a success. + /// The activation outcome CheatEngine.SDK reported. + /// The problem CheatEngine.SDK reported with it. + /// + /// for , + /// and + /// ; otherwise the failure kind and host effect, and + /// with + /// for an unrecognized kind. + /// + internal static (CheatEngineFailureKind Kind, CheatEngineHostEffect HostEffect)? ToActivationFailure( + MemoryRecordActivationOutcomeKind kind, MemoryRecordMutationProblem problem) + { + return kind switch + { + MemoryRecordActivationOutcomeKind.Applied => null, + MemoryRecordActivationOutcomeKind.Unchanged => null, + MemoryRecordActivationOutcomeKind.Pending => null, + MemoryRecordActivationOutcomeKind.RefusedByHost => + (CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.Started), + MemoryRecordActivationOutcomeKind.Indeterminate => + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Started), + MemoryRecordActivationOutcomeKind.NotAttempted => (ToFailureKind(problem), CheatEngineHostEffect.NotStarted), + MemoryRecordActivationOutcomeKind.Unknown => + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + _ => (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown) + }; + } + + /// Gets whether the port copies the record after an activation of this kind. + /// The activation outcome. + /// + /// when the setter ran or was not needed and its outcome is known: the record then has a + /// state worth returning. + /// + internal static bool CopiesRecord(MemoryRecordActivationOutcomeKind kind) + { + return kind is MemoryRecordActivationOutcomeKind.Applied or MemoryRecordActivationOutcomeKind.Unchanged + or MemoryRecordActivationOutcomeKind.Pending or MemoryRecordActivationOutcomeKind.RefusedByHost; + } + + /// Classifies one activation observation. + /// The public operation name. + /// The requested Active state. + /// The copied command facts and record. + /// The failure when the activation is not a success. + /// + /// for an applied, unchanged or pending activation whose record was copied; a success + /// whose record could not be copied is , never merged with + /// the command. + /// + internal static bool TryClassifyActivation(string operation, bool requested, TableActivationObservation observation, + out CheatEngineFailure failure) + { + if (ToActivationFailure(observation.Kind, observation.Problem) is not { } classified) + { + if (observation.Snapshot is not null) + { + failure = default; + return true; + } + + failure = observation.Kind switch + { + MemoryRecordActivationOutcomeKind.Unchanged => new CheatEngineFailure( + CheatEngineFailureKind.InvalidHostResult, operation, + "The memory record already had the requested state, but its snapshot could not be copied.", null, + CheatEngineHostEffect.NotStarted), + MemoryRecordActivationOutcomeKind.Pending => new CheatEngineFailure( + CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine started the asynchronous activation, but the record snapshot could not be copied.", + null, CheatEngineHostEffect.Started), + _ => new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine applied the requested state, but the record snapshot could not be copied.", null, + CheatEngineHostEffect.Completed) + }; + return false; + } + + string message = observation.Kind switch + { + MemoryRecordActivationOutcomeKind.RefusedByHost => + $"Cheat Engine left the memory record {(requested ? "inactive" : "active")}; an activation callback, " + + "script or record type refused the change; partial script effects may persist.", + MemoryRecordActivationOutcomeKind.Indeterminate => + "Cheat Engine ran the activation setter, but the record's state after it could not be established; " + + "its effect is unknown.", + MemoryRecordActivationOutcomeKind.NotAttempted => + Describe(TableRecordMutationOutcome.NotAttempted(observation.Problem)), + _ => "CheatEngine.SDK reported no recognized activation outcome." + }; + failure = new CheatEngineFailure(classified.Kind, operation, message, null, classified.HostEffect); + return false; + } + + /// + /// Returns the failure kind and host effect of a table file load or save, or for a success. + /// + /// The binding outcome that CheatTableFiles.TryLoad or TrySave reported. + /// + /// for ; otherwise the failure kind and host + /// effect, and with + /// for an unrecognized status. + /// + internal static (CheatEngineFailureKind Kind, CheatEngineHostEffect HostEffect)? ToTableFileFailure( + LuaOperationStatusKind status) + { + return status switch + { + LuaOperationStatusKind.Success => null, + LuaOperationStatusKind.GlobalUnavailable => + (CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted), + LuaOperationStatusKind.StackUnavailable => (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.NotStarted), + LuaOperationStatusKind.LuaFailure => (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Started), + LuaOperationStatusKind.NilResult => + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + LuaOperationStatusKind.InvalidResult => + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + LuaOperationStatusKind.MissingResult => + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + LuaOperationStatusKind.ResultCapacityExceeded => + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + LuaOperationStatusKind.Unknown => + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + _ => (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown) + }; + } + + /// Classifies the outcome of one table file load or save. + /// The public operation name. + /// for a load, for a save. + /// The binding outcome CheatEngine.SDK reported. + /// The failure when the outcome is not a success; its message never names the path. + /// only for . + internal static bool TryClassifyTableFile(string operation, bool load, LuaOperationStatusKind status, + out CheatEngineFailure failure) + { + if (ToTableFileFailure(status) is not { } classified) + { + failure = default; + return true; + } + + string action = load ? "load" : "save"; + string message = status switch + { + LuaOperationStatusKind.GlobalUnavailable => + $"Cheat Engine's table {action} function is unavailable; it was not called.", + LuaOperationStatusKind.StackUnavailable => + $"The Lua stack could not grow enough to call Cheat Engine's table {action} function; it was not called.", + LuaOperationStatusKind.LuaFailure when load => + "Cheat Engine's table load raised an error; part of the table and of its scripts may have been applied.", + LuaOperationStatusKind.LuaFailure => + "Cheat Engine's table save raised an error; the file may be partially written.", + LuaOperationStatusKind.NilResult or LuaOperationStatusKind.InvalidResult + or LuaOperationStatusKind.MissingResult or LuaOperationStatusKind.ResultCapacityExceeded => + $"Cheat Engine's table {action} returned a result outside its contract; its effect is unknown.", + _ => "CheatEngine.SDK reported no recognized table file outcome." + }; + failure = new CheatEngineFailure(classified.Kind, operation, message, null, classified.HostEffect); + return false; + } + + private static string Describe(TableRecordMutationOutcome outcome) + { + return outcome.Problem switch + { + MemoryRecordMutationProblem.AddressListUnavailable => AddressListUnavailableMessage, + MemoryRecordMutationProblem.GlobalUnavailable => "A Cheat Engine Address List function is unavailable.", + MemoryRecordMutationProblem.RecordNotFound => RecordNotFoundMessage, + MemoryRecordMutationProblem.ParentNotFound => + "The requested parent Cheat Engine memory record was not found.", + MemoryRecordMutationProblem.SelfParent => "A memory record cannot be its own parent.", + MemoryRecordMutationProblem.CycleDetected => + "The requested parent relationship would form a cycle in the Address List.", + MemoryRecordMutationProblem.TraversalLimitReached => + "The parent chain of the requested parent reaches the traversal limit of " + + $"{SdkTableRecordMutationPort.ParentTraversalHops} records; the record was not moved.", + MemoryRecordMutationProblem.LuaFailure when outcome.Effect == MemoryRecordMutationEffect.Indeterminate => + "A Cheat Engine Address List call raised after the change started; its effect is unknown.", + MemoryRecordMutationProblem.LuaFailure => + "A Cheat Engine Address List call raised before the change was attempted.", + MemoryRecordMutationProblem.InvalidResult when outcome.Effect == MemoryRecordMutationEffect.Completed => + "Cheat Engine completed the change, but the memory record could not be copied afterwards.", + MemoryRecordMutationProblem.InvalidResult => InvalidContractMessage, + MemoryRecordMutationProblem.TableLoadInProgress => + "A table file is loading on Cheat Engine's main thread (a script of that table called the Client); " + + "the Address List was not changed.", + MemoryRecordMutationProblem.RuntimeIdentityChanged => + "Cheat Engine's Lua runtime changed before the change was attempted; the memory record was not changed.", + _ => "CheatEngine.SDK reported no recognized Address List mutation outcome." + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs deleted file mode 100644 index cdd3848..0000000 --- a/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CheatEngine.SDK.Engine.AddressList; - -namespace CheatEngine.Client.Core.Domains; - -/// Bounded, handle-free validation of a parent chain before Cheat Engine is asked to mutate it. -internal static class TableParentRelationshipGuard -{ - internal static TableRecordMutationStatus Validate(MemoryRecordId childId, MemoryRecordId candidateParentId, - int maximumHops, Func getNext) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumHops); - ArgumentNullException.ThrowIfNull(getNext); - - HashSet visited = []; - MemoryRecordId current = candidateParentId; - for (int hop = 0; hop < maximumHops; hop++) - { - if (current == childId || !visited.Add(current)) - { - return TableRecordMutationStatus.InvalidRelationship; - } - - ParentChainStep step = getNext(current); - switch (step.Kind) - { - case ParentChainStepKind.Root: - return TableRecordMutationStatus.Success; - case ParentChainStepKind.Parent: - current = step.ParentId; - break; - case ParentChainStepKind.HostRejected: - return TableRecordMutationStatus.HostRejected; - default: - return TableRecordMutationStatus.HostRejected; - } - } - - return TableRecordMutationStatus.InvalidRelationship; - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/TableRecordCreation.cs b/libs/CheatEngine.Client.Core/Domains/TableRecordCreation.cs new file mode 100644 index 0000000..7b48f10 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableRecordCreation.cs @@ -0,0 +1,32 @@ +namespace CheatEngine.Client.Core.Domains; + +/// States whether a failed Address List record creation was rolled back. +internal enum TableRecordRollback +{ + /// No record was created, or the creation succeeded: nothing had to be rolled back. + NotRequired, + + /// The partially initialized record was deleted once and Cheat Engine confirmed it. + Confirmed, + + /// + /// The delete did not complete or faulted, or could not be attempted because the record's identifier was not + /// readable: the record may remain in the Address List. + /// + Unconfirmed +} + +/// The outcome of creating one Address List record, including the rollback of a failed creation. +/// The creation or parent-assignment outcome. +/// Whether a partially initialized record was rolled back. +/// The SDK fault that interrupted the creation, if any. +/// The SDK fault raised by the single rollback attempt, if any. +internal readonly record struct TableRecordCreation( + TableRecordMutationOutcome Outcome, + TableRecordRollback Rollback, + Exception? Fault = null, + Exception? RollbackFault = null) +{ + internal static TableRecordCreation Created => + new(TableRecordMutationOutcome.Succeeded, TableRecordRollback.NotRequired); +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableRecordGeneration.cs b/libs/CheatEngine.Client.Core/Domains/TableRecordGeneration.cs new file mode 100644 index 0000000..06c9bbe --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableRecordGeneration.cs @@ -0,0 +1,120 @@ +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Binds the memory-record identifiers that one Client activation handed out to the table load they were observed in +/// (audit ch.14, A14-01, A14-05, A14-29, Q34). +/// +/// +/// +/// Every copied record snapshot marks its identifier as current. A trusted table load that reached Cheat Engine +/// (merge or replace, whatever its result: Cheat Engine may already have cleared the table) advances the +/// generation and turns every current identifier stale, because Cheat Engine does not promise that an identifier +/// survives a load. A stale identifier becomes current again when a later snapshot observes it. +/// +/// +/// Ordering: runs inside the dispatched load callback, and every dispatched lookup or +/// mutation reads on Cheat Engine's main thread when it copies its snapshot. The main +/// thread runs dispatched callbacks one at a time, so the generation a snapshot carries is the table load it was +/// copied from, whatever the order in which the calling workers resume afterwards. +/// therefore judges each snapshot by the generation it was copied in: a snapshot copied before a later load +/// hands out stale identifiers, never current ones. +/// +/// +/// An identifier this activation never handed out is not judged (unknown provenance). A reload by the user, a +/// script or another plugin is not observed, and a successful delete does not make an identifier stale (a second +/// delete reports not found). +/// +/// +internal sealed class TableRecordGeneration +{ + private readonly HashSet _current = []; + private readonly Lock _gate = new(); + private readonly HashSet _stale = []; + private long _generation; + + /// Gets the number of trusted table loads of this activation that reached Cheat Engine. + /// Read it on Cheat Engine's main thread, inside the dispatched callback that copies a snapshot. + internal long Generation => Volatile.Read(ref _generation); + + /// Records the identifier of a record copied in . + internal void Observe(MemoryRecordSnapshot record, long observedGeneration) + { + lock (_gate) + { + ObserveCore(record.Id, observedGeneration); + } + } + + /// Records every identifier of a table snapshot copied in . + internal void Observe(AddressTableSnapshot table, long observedGeneration) + { + lock (_gate) + { + foreach (MemoryRecordSnapshot record in table.Records) + { + ObserveCore(record.Id, observedGeneration); + } + } + } + + /// Records every identifier of a hierarchy copied in . + internal void Observe(MemoryRecordHierarchySnapshot hierarchy, long observedGeneration) + { + lock (_gate) + { + ObserveHierarchy(hierarchy, observedGeneration); + } + } + + /// Gets whether the identifier was handed out before the last trusted load and not observed since. + internal bool IsStale(MemoryRecordId id) + { + lock (_gate) + { + return _stale.Contains(id); + } + } + + /// + /// Advances the generation after a trusted load reached Cheat Engine; returns the new generation. Call it inside the + /// dispatched load callback, so no snapshot copied after the load can carry the previous generation. + /// + internal long Advance() + { + lock (_gate) + { + _stale.UnionWith(_current); + _current.Clear(); + return Interlocked.Increment(ref _generation); + } + } + + private void ObserveHierarchy(MemoryRecordHierarchySnapshot hierarchy, long observedGeneration) + { + ObserveCore(hierarchy.Record.Id, observedGeneration); + foreach (MemoryRecordHierarchySnapshot child in hierarchy.Children) + { + ObserveHierarchy(child, observedGeneration); + } + } + + private void ObserveCore(MemoryRecordId id, long observedGeneration) + { + if (observedGeneration == _generation) + { + _current.Add(id); + _stale.Remove(id); + return; + } + + // The snapshot was copied before a trusted load that has since advanced the generation: it hands out an + // identifier of an earlier table state. It stays refused unless a snapshot of the current load observed it. + if (!_current.Contains(id)) + { + _stale.Add(id); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableRecordMutationOutcome.cs b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationOutcome.cs new file mode 100644 index 0000000..630dc3d --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationOutcome.cs @@ -0,0 +1,61 @@ +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// The copied facts of one Address List record mutation, in CheatEngine.SDK's mutation vocabulary. +/// +/// +/// AddressListMutations.Delete and AddressListMutations.SetParent report these two facts +/// themselves. The Client's own create and select steps, for which CheatEngine.SDK 2.0.0 has no +/// AddressListMutations command, report theirs in the same vocabulary, so +/// classifies every record mutation once. +/// +/// +/// The default value is and never reads as a success. +/// +/// +/// How far the mutation progressed at the Cheat Engine boundary. +/// The problem, or for a success. +internal readonly record struct TableRecordMutationOutcome( + MemoryRecordMutationEffect Effect, + MemoryRecordMutationProblem Problem) +{ + /// Gets the outcome of a mutation that completed with no problem. + internal static TableRecordMutationOutcome Succeeded => + new(MemoryRecordMutationEffect.Completed, MemoryRecordMutationProblem.None); + + /// + /// Gets the outcome of a mutation that completed when the record could not be copied afterwards. CheatEngine.SDK + /// asks callers never to merge such a read failure with the command result, so it is its own invalid result + /// after a completed command. + /// + internal static TableRecordMutationOutcome CompletedWithoutSnapshot => + new(MemoryRecordMutationEffect.Completed, MemoryRecordMutationProblem.InvalidResult); + + /// + /// Gets the outcome of a Cheat Engine call that was invoked and returned no usable result, so whether it changed + /// the Address List is not established. + /// + internal static TableRecordMutationOutcome InvalidResultAfterInvocation => + new(MemoryRecordMutationEffect.Indeterminate, MemoryRecordMutationProblem.InvalidResult); + + /// Gets whether the mutation completed with no problem. + internal bool IsSuccess => + Effect == MemoryRecordMutationEffect.Completed && Problem == MemoryRecordMutationProblem.None; + + /// Creates the outcome of a mutation refused before any Cheat Engine change was attempted. + /// Why the mutation was not attempted. + /// A outcome. + internal static TableRecordMutationOutcome NotAttempted(MemoryRecordMutationProblem problem) + { + return new TableRecordMutationOutcome(MemoryRecordMutationEffect.NotAttempted, problem); + } + + /// Copies the facts of an AddressListMutations command. + /// The command result CheatEngine.SDK returned. + /// The copied effect and problem. + internal static TableRecordMutationOutcome From(MemoryRecordMutationOutcome outcome) + { + return new TableRecordMutationOutcome(outcome.Effect, outcome.Problem); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs deleted file mode 100644 index e0b4b5e..0000000 --- a/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CheatEngine.Client.Core.Domains; - -/// Outcome of one protected address-list record mutation. -internal enum TableRecordMutationStatus -{ - Success, - RecordNotFound, - ParentNotFound, - InvalidRelationship, - HostRejected -} diff --git a/libs/CheatEngine.Client.Core/Domains/TargetArchitectureObserver.cs b/libs/CheatEngine.Client.Core/Domains/TargetArchitectureObserver.cs new file mode 100644 index 0000000..58b65b8 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TargetArchitectureObserver.cs @@ -0,0 +1,142 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// The one target observation policy of the Runtime, Processes and Memory domains, over the read-only +/// CheatEngine.SDK 2.0.0 process operations (audit F08, A10-17, Q31, Q32). +/// +/// +/// +/// The observation is RuntimeProcessOperations.ObserveTargetArchitecture: the SDK reads the selected PID, +/// the backend, the bitness, the ISA families, the Android and ABI facts and the configured pointer size, then the +/// selected PID again, in one Lua admission. It reads no fact when no target or the file-as-process sentinel is +/// selected, and it reports a different closing PID as a target change. The ISA is the SDK's own derivation +/// (), never a Client inference from the 64-bit fact. +/// +/// +/// When that observation raises a protected Lua error or returns a malformed value, one of its facts is broken +/// but the others may not be. The observer then narrows: ObserveCurrent (PID and bitness), +/// TryGetConfiguredPointerSize, and ObserveCurrent again. The narrowed facts are kept only when +/// both PID reads succeed and agree; a different, absent or file-as-process closing selection is a target change. +/// The narrowed observation leaves the backend, the ISA, the Android and ABI facts unknown. Like the SDK's own +/// bracket, the two reads do not detect a selection that changed and changed back between them. +/// +/// +/// Every call is an observation: nothing selects, opens, pauses or configures a target (Q45). +/// +/// +internal static class TargetArchitectureObserver +{ + /// Observes the selected target, narrowing to the facts that can still be read when one fact is broken. + /// The read-only target observation port. + /// The copied observation and its status. + internal static ObservedTarget Observe(ITargetObservationPort port) + { + ArgumentNullException.ThrowIfNull(port); + ProcessOperationStatus status = port.ObserveTargetArchitecture(out TargetArchitectureObservation facts); + if (status.IsSuccess) + { + return new ObservedTarget(status, facts, null); + } + + return status.Kind is ProcessOperationStatusKind.ProtectedLuaFailure or ProcessOperationStatusKind.InvalidResult + ? ObserveNarrowly(port, status) + : new ObservedTarget(status, default, null); + } + + private static ObservedTarget ObserveNarrowly(ITargetObservationPort port, ProcessOperationStatus fullStatus) + { + ProcessOperationStatus opening = port.ObserveCurrent(out CurrentProcessObservation selected); + if (!opening.IsSuccess) + { + return new ObservedTarget(opening, default, fullStatus); + } + + ProcessOperationStatus configured = port.TryGetConfiguredPointerSize(out int rawBytes, out _); + ProcessOperationStatus closing = port.ObserveCurrent(out CurrentProcessObservation confirmed); + ProcessOperationStatus confirmation = closing.Kind switch + { + ProcessOperationStatusKind.Success when confirmed.Id == selected.Id => ProcessOperationStatus.Success, + ProcessOperationStatusKind.Success or ProcessOperationStatusKind.TargetNotAttached + or ProcessOperationStatusKind.FileAsProcessTarget => ProcessOperationStatus.TargetChanged, + _ => closing + }; + if (!confirmation.IsSuccess) + { + return new ObservedTarget(confirmation, default, fullStatus); + } + + // TryGetConfiguredPointerSize keeps the raw integer of an InvalidResult width (any value other than 4 and 8); + // zero there means that no integer was read, so it stays unknown. + int? configuredBytes = configured.IsSuccess || + (configured.Kind == ProcessOperationStatusKind.InvalidResult && rawBytes != 0) + ? rawBytes + : null; + TargetArchitectureObservation narrowed = new(selected.Id, TargetBackend.Unknown, selected.PointerSize, null, + null, null, null, configuredBytes); + return new ObservedTarget(ProcessOperationStatus.Success, narrowed, fullStatus); + } +} + +/// The copied result of one target observation. +/// +/// Successful when target facts were established (fully or narrowed); otherwise the SDK status that explains why no +/// fact is attributed to a target. +/// +/// The copied facts; meaningful only when is . +/// +/// The status of the full observation when the observer had to narrow, or when the full +/// observation answered. +/// +internal readonly record struct ObservedTarget( + ProcessOperationStatus Status, + TargetArchitectureObservation Facts, + ProcessOperationStatus? NarrowedFrom) +{ + /// Gets whether target facts were established for one selected process. + internal bool HasTarget => Status.IsSuccess; + + /// Gets whether Cheat Engine reported that no target is selected. + internal bool NoTargetSelected => Status.Kind == ProcessOperationStatusKind.TargetNotAttached; + + /// Gets the selected process identifier, or without a target. + internal TargetProcessId? ProcessId => HasTarget ? Facts.ProcessId : null; + + /// + /// Gets how Cheat Engine reaches the target: the SDK's backend fact, + /// for the file-as-process sentinel, otherwise unknown. + /// + internal TargetBackend Backend => HasTarget + ? Facts.Backend + : Status.Kind == ProcessOperationStatusKind.FileAsProcessTarget + ? TargetBackend.FileAsProcess + : TargetBackend.Unknown; + + /// Gets the target bitness (targetIs64Bit, what readPointer follows), or unknown. + internal PointerSize Bitness => HasTarget ? Facts.Bitness : PointerSize.Unknown; + + /// Gets the ISA the SDK derived from the family and bitness facts, or unknown. + internal CheatEngineArchitecture Architecture => HasTarget ? Facts.Architecture : CheatEngineArchitecture.Unknown; + + /// Gets the decoded target ABI, or unknown. + internal TargetAbi Abi => HasTarget ? Facts.Abi : TargetAbi.Unknown; + + /// Gets whether the target is Android, or when unknown. + internal bool? IsAndroid => HasTarget ? Facts.IsAndroid : null; + + /// Gets the raw configured pointer size, or when it was not observed. + internal int? ConfiguredPointerSizeBytes => HasTarget ? Facts.ConfiguredPointerSizeBytes : null; + + /// Gets the configured pointer size as a width when it is 4 or 8 bytes, otherwise unknown. + internal PointerSize ConfiguredPointerSize => HasTarget ? Facts.ConfiguredPointerSize : PointerSize.Unknown; + + /// + /// Gets whether an observed configured pointer size differs from the known bitness (audit Q31.a); + /// when either fact is unknown, which is no evidence of a mismatch. + /// + internal bool ConfiguredPointerSizeDiffersFromBitness => + HasTarget && Facts.ConfiguredPointerSizeDiffersFromBitness == true; +} diff --git a/libs/CheatEngine.Client.Core/Domains/TargetSelectionFacts.cs b/libs/CheatEngine.Client.Core/Domains/TargetSelectionFacts.cs new file mode 100644 index 0000000..1047ca3 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TargetSelectionFacts.cs @@ -0,0 +1,33 @@ +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains; + +/// A copied CheatEngine.SDK TargetSelectionObservation: what identifies the selected target. +/// +/// The SDK type has an internal constructor, so the port copies it into this value and the Processes domain is +/// testable without a host. Only a local process yields an : its PID and the creation time +/// the SDK observed, read in the same Lua operation as the backend fact. +/// +/// The factual observation category. +/// The backend the SDK established for the selection. +/// The selected PID when Cheat Engine reported one. +/// The local process incarnation, only for a qualified local selection. +internal readonly record struct TargetSelectionFacts( + TargetSelectionObservationStatus Status, + TargetBackend Backend, + int? SelectedProcessId, + TargetProcessIncarnation? Incarnation) +{ + /// + /// Gets whether the observation identifies a local process incarnation, the SDK's + /// TargetSelectionObservation.IsQualified. + /// + internal bool IsQualified => + Status == TargetSelectionObservationStatus.CurrentTargetQualified && Incarnation.HasValue; +} + +/// A copied CheatEngine.SDK TargetIdentityCheck: a known incarnation compared with the selection. +/// The factual validation category. +/// The selection observation the SDK compared. +internal readonly record struct TargetIdentityFacts(TargetIdentityCheckKind Kind, TargetSelectionFacts Observed); diff --git a/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs b/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs deleted file mode 100644 index 0333f26..0000000 --- a/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Events; -using CheatEngine.Client.Results; -using CheatEngine.Client.Timers; - -namespace CheatEngine.Client.Core.Domains.Timers; - -/// Preserves recurring timer semantics until timer callback cleanup passes the live-host gate. -internal sealed class UnavailableTimerClient : ITimerClient -{ - private readonly CoreLifetime? _lifetime; - - internal UnavailableTimerClient(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryRegister(TimerRequest request, TimerHandler handler, EventStreamOptions streamOptions, - [NotNullWhen(true)] out ITimerLease? lease, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(handler); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); - lease = null; - failure = UnavailableCapabilityFailure.Create(_lifetime, "Timers", "Timers.Register", cancellationToken); - return false; - } - - public ITimerLease Register(TimerRequest request, TimerHandler handler, EventStreamOptions streamOptions, - CancellationToken cancellationToken = default) - { - _ = TryRegister(request, handler, streamOptions, out _, out CheatEngineFailure failure, cancellationToken); - return UnavailableCapabilityFailure.Throw(failure); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs deleted file mode 100644 index b5f9aac..0000000 --- a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Diagnostics; - -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Results; -using CheatEngine.Client.Scanning; - -namespace CheatEngine.Client.Core.Domains; - -/// -/// Keeps the public capability honest until the owned MemScan/FoundList lifecycle passes the required live CE 7.7 -/// gate. -/// -internal sealed class UnavailableValueScanner : IValueScanner -{ - // This is a deliberate product gate, not a transient host-capability probe. SDK 1.0.0 exposes the state machine - // only through MemoryScanSession.Adopt(Owned, Owned), while Owned has an internal - // constructor and the Lua-global generator cannot marshal CEObject results. Bypassing that with reflection or a - // hand-rolled destroy owner would make an unverified CE ownership assumption part of the Client contract. - private const string _ownershipGateMessage = - "Value scans are disabled: CheatEngine.SDK 1.0.0 has no public ownership factory for createMemScan or " + - "createFoundList, and its generated Lua globals cannot return CEObject handles. Enablement requires the " + - "Cheat Engine 7.7 ownership and reactivation live gate."; - - private readonly CoreLifetime? _lifetime; - - internal UnavailableValueScanner(CoreLifetime? lifetime = null) - { - _lifetime = lifetime; - } - - public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - session = null; - _lifetime?.ThrowIfInactive("Scans.CreateSession"); - failure = cancellationToken.IsCancellationRequested - ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Scans.CreateSession", - "The operation was cancelled before Cheat Engine work began.") - : new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Scans.CreateSession", - _ownershipGateMessage); - return false; - } - - public IValueScanSession CreateSession(CancellationToken cancellationToken = default) - { - _ = TryCreateSession(out _, out CheatEngineFailure failure, cancellationToken); - return ThrowFailure(failure); - } - - private static T ThrowFailure(CheatEngineFailure failure) - { - failure.Throw(); - throw new UnreachableException(); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs index a0e2012..28f0f76 100644 --- a/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text; using CheatEngine.Client.Core.Dispatching; @@ -5,6 +6,7 @@ using CheatEngine.Client.Dispatching; using CheatEngine.Client.Lua; using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -13,6 +15,8 @@ namespace CheatEngine.Client.Core.Domains; internal sealed class UnsafeLuaClient : IUnsafeLuaClient { + private const string Operation = "UnsafeLua.Execute"; + private readonly ICheatEngineDispatcher _dispatcher; private readonly CoreLifetime? _lifetime; private readonly CoreClientPolicy _policy; @@ -40,36 +44,66 @@ public bool TryExecute(LuaScript script, out CheatEngineFailure failure, throw new ArgumentException("A Lua chunk name must be null or non-empty.", nameof(script)); } - _lifetime?.ThrowIfInactive("Lua.ExecuteUnsafe"); + _lifetime?.ThrowIfInactive(Operation); if (!_policy.EnableUnsafeLuaExecution) { - failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Lua.ExecuteUnsafe", - "Arbitrary Lua execution was not enabled for this activation."); + _lifetime?.Diagnostics.CapabilityRefused(ClientCapabilityId.UnsafeLuaExecution.Value, Operation, + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, Operation, + "Arbitrary Lua execution was not enabled for this activation.", null, CheatEngineHostEffect.NotStarted); return false; } + long started = Stopwatch.GetTimestamp(); + bool completed = TryExecuteCore(script, out failure, cancellationToken); + // Size and duration only: the script body and the Lua error text are never logged (A24-14). + _lifetime?.Diagnostics.LuaOperationCompleted(Operation, + completed ? "None" : failure.Kind.ToString(), (long) Stopwatch.GetElapsedTime(started).TotalMilliseconds, + script.Source.Length); + return completed; + } + + private bool TryExecuteCore(LuaScript script, out CheatEngineFailure failure, CancellationToken cancellationToken) + { string luaStatus = "unknown"; string? luaMessage = null; bool succeeded = false; - if (!_dispatcher.TryInvoke(() => - { - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - LuaState state = operation.State; - using LuaFrame frame = new(state); - byte[] source = Encoding.UTF8.GetBytes(script.Source); - ReadOnlySpan name = script.ChunkName is null - ? ReadOnlySpan.Empty - : Encoding.UTF8.GetBytes(script.ChunkName); - LuaStatus status = state.TryExecute(source, 0, name); - succeeded = status.IsOk; - luaStatus = status.ToString(); - if (!succeeded) - { - luaMessage = LuaError.FromStack(state, status).Message; - } - }, out failure, cancellationToken)) + bool admitted = false; + CheatEngineFailure admissionFailure = default; + // A refused Lua admission is classified from the SDK's admission status (NotStarted). A protected Lua failure is a + // returned status; only SDK faults are translated, with an unknown effect because the script may have run + // partially. + if (!SdkBoundary.TryInvoke(_dispatcher, Operation, () => + { + if (!LuaAdmission.TryAcquire(Operation, out LuaRuntimeOperation acquired, out admissionFailure)) + { + return; + } + + admitted = true; + using LuaRuntimeOperation operation = acquired; + LuaState state = operation.State; + using LuaFrame frame = new(state); + byte[] source = Encoding.UTF8.GetBytes(script.Source); + ReadOnlySpan name = script.ChunkName is null + ? ReadOnlySpan.Empty + : Encoding.UTF8.GetBytes(script.ChunkName); + LuaStatus status = state.TryExecute(source, 0, name); + succeeded = status.IsOk; + luaStatus = status.ToString(); + if (!succeeded) + { + luaMessage = LuaError.FromStack(state, status).Message; + } + }, CheatEngineHostEffect.Unknown, _lifetime, out failure, cancellationToken)) + { + return false; + } + + if (!admitted) { + failure = admissionFailure; return false; } @@ -78,7 +112,7 @@ public bool TryExecute(LuaScript script, out CheatEngineFailure failure, return true; } - failure = new CheatEngineFailure(CheatEngineFailureKind.LuaError, "Lua.ExecuteUnsafe", + failure = new CheatEngineFailure(CheatEngineFailureKind.LuaError, Operation, luaMessage is { Length: > 0 } ? $"The protected Lua call failed with status '{luaStatus}': {luaMessage}" : $"The protected Lua call failed with status '{luaStatus}'."); @@ -89,7 +123,7 @@ public void Execute(LuaScript script, CancellationToken cancellationToken = defa { if (!TryExecute(script, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } } diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs deleted file mode 100644 index c52e62b..0000000 --- a/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs +++ /dev/null @@ -1,85 +0,0 @@ -using CheatEngine.Client.Scanning; - -namespace CheatEngine.Client.Core.Domains; - -/// -/// Keeps the client-visible value-scan lifecycle conservative and independent of SDK object handles. -/// -/// -/// This state machine is deliberately usable without a live Cheat Engine host. The eventual CE-backed session must -/// call -/// or immediately before work begins, then either -/// after wait-and-initialize succeeds or when a protected Lua -/// call -/// leaves the safe continuation state unknown. -/// -internal sealed class ValueScanSessionStateMachine -{ - public ValueScanSessionState State - { - get; - private set; - } = ValueScanSessionState.Created; - - internal void BeginFirstScan() - { - Require(ValueScanSessionState.Created, "a first scan"); - State = ValueScanSessionState.Scanning; - } - - internal void BeginNextScan() - { - Require(ValueScanSessionState.ResultsReady, "a subsequent scan"); - State = ValueScanSessionState.Scanning; - } - - internal void CompleteScan() - { - Require(ValueScanSessionState.Scanning, "scan completion"); - State = ValueScanSessionState.ResultsReady; - } - - internal void Invalidate() - { - if (State != ValueScanSessionState.Disposed) - { - State = ValueScanSessionState.Invalidated; - } - } - - internal void Reset() - { - if (State == ValueScanSessionState.Created) - { - return; - } - - if (State is ValueScanSessionState.Scanning or ValueScanSessionState.Disposed) - { - ThrowInvalidTransition("a reset"); - } - - State = ValueScanSessionState.Created; - } - - internal void MarkDisposed() - { - State = ValueScanSessionState.Disposed; - } - - private void Require(ValueScanSessionState expected, string operation) - { - if (State == expected) - { - return; - } - - ThrowInvalidTransition(operation); - } - - private void ThrowInvalidTransition(string operation) - { - throw new InvalidOperationException( - $"The value-scan session is {State} and cannot begin {operation}."); - } -} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanning/IValueScanPort.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanning/IValueScanPort.cs new file mode 100644 index 0000000..7172f83 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanning/IValueScanPort.cs @@ -0,0 +1,85 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains.ValueScanning; + +/// Internal boundary that creates CheatEngine.SDK value-scan sessions; Core tests replace it with doubles. +/// Every member runs on Cheat Engine's main thread, inside a dispatched callback. +internal interface IValueScanPort +{ + /// Creates a scanner and its found-list child for Cheat Engine's selected target. + /// The session, only when the returned status is . + /// The factual creation status of MemoryScanSessions.TryCreateWithOutcome. + public MemoryScanCreationStatus TryCreate(out IValueScanSessionHandle? session); +} + +/// +/// Internal view of one CheatEngine.SDK MemoryScanSession: the stable members only, never the raw +/// Scanner or Results handles (CESDK1001) nor the experimental timed wait and stop (CESDK5010). +/// +/// Every member runs on Cheat Engine's main thread, inside a dispatched callback. +internal interface IValueScanSessionHandle +{ + /// + /// Gets the qualified process incarnation that CheatEngine.SDK observed before it created the session + /// (TargetObservation.Incarnation), which every later session operation and the release check. + /// + public TargetProcessIncarnation TargetIncarnation + { + get; + } + + /// Gets the SDK session state. + public MemoryScanState State + { + get; + } + + /// Gets why the SDK invalidated the session, or . + public MemoryScanInvalidationReason InvalidationReason + { + get; + } + + /// Gets how cancellation met the last cancellable SDK operation. + public MemoryScanCancellationMilestone LastCancellationMilestone + { + get; + } + + /// Reads the number of initialized results; throws when the SDK refuses or Cheat Engine fails. + public ulong ReadResultCount(); + + /// Starts a first scan (StartFirstScanCancellable). + public void StartFirstScan(in FirstScanRequest request, CancellationToken cancellationToken); + + /// Starts a next scan (StartNextScanCancellable). + public void StartNextScan(in NextScanRequest request, CancellationToken cancellationToken); + + /// Waits for the running scan and initializes its results (WaitForCompletionCancellable). + public void WaitForCompletion(CancellationToken cancellationToken); + + /// Clears the results (ResetCancellable). + public void Reset(CancellationToken cancellationToken); + + /// Copies one bounded page of results (TryCopyResultsPageCancellable). + public MemoryScanMaterializationStatus TryCopyResultsPage(int firstResultIndex, + Span destination, out ulong totalCount, out int written, CancellationToken cancellationToken); + + /// Copies Cheat Engine's bounded error text of the scan, when it has one. + public bool TryGetHostErrorText([NotNullWhen(true)] out string? text, out bool truncated); + + /// Releases the found list, then the scanner, once (ReleaseWithOutcome); never throws. + public ValueScanReleaseStatuses Release(); +} + +/// The SDK release status of each owner of a value-scan session, and of the stop of a running scan. +/// The status of the found-list child, released first. +/// The status of the scanner parent. +/// How a scan that may still have been running was stopped before the release. +internal readonly record struct ValueScanReleaseStatuses( + TargetReleaseStatus FoundList, + TargetReleaseStatus MemScan, + MemoryScanTerminationStatus Termination); diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanning/SdkValueScanPort.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanning/SdkValueScanPort.cs new file mode 100644 index 0000000..936840b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanning/SdkValueScanPort.cs @@ -0,0 +1,118 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Domains.ValueScanning; + +/// Production adapter over MemoryScanSessions and MemoryScanSession of CheatEngine.SDK 2.0.0. +/// +/// +/// Creation goes through , the only SDK path that turns the +/// createMemScan and createFoundList results into owners, for a qualified target only, and rolls the +/// child back before the parent when creation fails. The adapter keeps the SDK session as the single owner of both +/// objects; it never reads the raw Scanner or Results handles and never calls the experimental +/// TryWaitForCompletion or TryTerminateScan. +/// +/// +/// Only a hosted Cheat Engine can reach this adapter: it is excluded from the coverage metric, and Core tests +/// exercise the value-scan domain through doubles. +/// +/// +internal sealed class SdkValueScanPort : IValueScanPort +{ + private SdkValueScanPort() + { + } + + /// Gets the production adapter. + internal static SdkValueScanPort Instance + { + get; + } = new(); + + public MemoryScanCreationStatus TryCreate(out IValueScanSessionHandle? session) + { + MemoryScanCreationOutcome outcome = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created); + if (outcome.Status == MemoryScanCreationStatus.Success && created is not null && + outcome.TargetObservation.Incarnation is { } incarnation) + { + session = new SdkValueScanSessionHandle(created, incarnation); + return outcome.Status; + } + + // The SDK publishes a session only with Success, and only for a qualified target; release one that a contract + // break would publish anyway rather than leak its Cheat Engine objects, and never report that nothing remains. + session = null; + if (created is null) + { + return outcome.Status; + } + + _ = created.ReleaseWithOutcome(); + return outcome.Status == MemoryScanCreationStatus.Success + ? MemoryScanCreationStatus.RollbackUnconfirmed + : outcome.Status; + } + + private sealed class SdkValueScanSessionHandle(MemoryScanSession session, TargetProcessIncarnation incarnation) + : IValueScanSessionHandle + { + private readonly MemoryScanSession _session = session ?? throw new ArgumentNullException(nameof(session)); + + public TargetProcessIncarnation TargetIncarnation + { + get; + } = incarnation; + + public MemoryScanState State => _session.State; + + public MemoryScanInvalidationReason InvalidationReason => _session.InvalidationReason; + + public MemoryScanCancellationMilestone LastCancellationMilestone => _session.LastCancellationMilestone; + + public ulong ReadResultCount() + { + return _session.ResultCount; + } + + public void StartFirstScan(in FirstScanRequest request, CancellationToken cancellationToken) + { + _session.StartFirstScanCancellable(in request, cancellationToken); + } + + public void StartNextScan(in NextScanRequest request, CancellationToken cancellationToken) + { + _session.StartNextScanCancellable(in request, cancellationToken); + } + + public void WaitForCompletion(CancellationToken cancellationToken) + { + _session.WaitForCompletionCancellable(cancellationToken); + } + + public void Reset(CancellationToken cancellationToken) + { + _session.ResetCancellable(cancellationToken); + } + + public MemoryScanMaterializationStatus TryCopyResultsPage(int firstResultIndex, + Span destination, out ulong totalCount, out int written, + CancellationToken cancellationToken) + { + return _session.TryCopyResultsPageCancellable(firstResultIndex, destination, out totalCount, out written, + cancellationToken); + } + + public bool TryGetHostErrorText([NotNullWhen(true)] out string? text, out bool truncated) + { + return _session.TryGetHostErrorText(out text, out truncated); + } + + public ValueScanReleaseStatuses Release() + { + MemoryScanReleaseOutcome outcome = _session.ReleaseWithOutcome(); + return new ValueScanReleaseStatuses(outcome.FoundList.Status, outcome.MemScan.Status, outcome.Termination); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanMapping.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanMapping.cs new file mode 100644 index 0000000..c61fbfa --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanMapping.cs @@ -0,0 +1,293 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Domains.ValueScanning; + +/// Maps every value-scan outcome that CheatEngine.SDK 2.0.0 reports to the Client vocabulary. +/// +/// +/// Each mapping is total over its SDK enum, and a value this Client version does not know fails closed +/// (, , or +/// the Unknown member of the Client enum); the mapping-totality tests fail when the consumed SDK adds a +/// value. Exceptions are classified by ; this type only decides how far the Cheat Engine +/// call got when one was thrown. +/// +/// +/// A creation that the SDK refused after it created an object has already been rolled back by the SDK, child +/// before parent: nothing that the Client owns remains, which +/// reports. may leave a scanner in Cheat Engine, and so +/// may a status that contradicts the published session or that this Client version does not recognize, since +/// nothing proves that no object remains: all three are +/// with +/// , as on the bounded AOB route. +/// +/// +internal static class ValueScanMapping +{ + /// Maps a creation status other than to its failure. + /// The status of MemoryScanSessions.TryCreateWithOutcome. + /// The public Client operation name. + /// + /// The classified failure; a success without a session is a contract break, reported like an unrecognized status. + /// + internal static CheatEngineFailure FromCreationStatus(MemoryScanCreationStatus status, string operation) + { + return status switch + { + MemoryScanCreationStatus.TargetIdentityUnavailable => Failure( + CheatEngineFailureKind.TargetIdentityUnavailable, operation, CheatEngineHostEffect.NotStarted, + "The identity of Cheat Engine's selected target could not be established, so no scan object was created."), + MemoryScanCreationStatus.GlobalUnavailable => Failure(CheatEngineFailureKind.CapabilityUnavailable, + operation, CheatEngineHostEffect.NotApplied, + "Cheat Engine's createMemScan or createFoundList global is unavailable; no scan session exists."), + MemoryScanCreationStatus.LuaFailure => Failure(CheatEngineFailureKind.LuaError, operation, + CheatEngineHostEffect.Unknown, + "A Cheat Engine scan factory raised a Lua error; no scan session exists."), + MemoryScanCreationStatus.NoScannerResult or MemoryScanCreationStatus.NoFoundListResult => Failure( + CheatEngineFailureKind.OperationRejected, operation, CheatEngineHostEffect.NotApplied, + "A Cheat Engine scan factory returned nil; no scan session exists."), + MemoryScanCreationStatus.InvalidScannerResult or MemoryScanCreationStatus.InvalidFoundListResult + or MemoryScanCreationStatus.AliasedFoundList => Failure(CheatEngineFailureKind.InvalidHostResult, + operation, CheatEngineHostEffect.NotApplied, + "A Cheat Engine scan factory returned a value that is not a distinct Cheat Engine object; no scan " + + "session exists."), + MemoryScanCreationStatus.RollbackUnconfirmed => Failure(CheatEngineFailureKind.IndeterminateHostResult, + operation, CheatEngineHostEffect.CleanupUnconfirmed, + "Creating the scan session failed and Cheat Engine did not confirm the destruction of the objects it " + + "had created: a scanner may remain in Cheat Engine."), + MemoryScanCreationStatus.Success => Failure(CheatEngineFailureKind.IndeterminateHostResult, operation, + CheatEngineHostEffect.CleanupUnconfirmed, + "CheatEngine.SDK reported a created scan session but published none: a scanner may remain in Cheat " + + "Engine."), + _ => Failure(CheatEngineFailureKind.IndeterminateHostResult, operation, + CheatEngineHostEffect.CleanupUnconfirmed, + "CheatEngine.SDK reported no recognized scan-session creation status, so no scan object is known to " + + "have been removed.") + }; + } + + /// Maps the SDK session state to the Client session state. + /// The state reported by CheatEngine.SDK. + /// The Client state; for an unrecognized value. + internal static ValueScanSessionState ToState(MemoryScanState state) + { + return state switch + { + MemoryScanState.New => ValueScanSessionState.Created, + MemoryScanState.Scanning => ValueScanSessionState.Scanning, + MemoryScanState.ResultsReady => ValueScanSessionState.ResultsReady, + MemoryScanState.Invalidated => ValueScanSessionState.Invalidated, + MemoryScanState.Disposed => ValueScanSessionState.Closed, + _ => ValueScanSessionState.Unknown + }; + } + + /// Maps the SDK invalidation reason to the Client invalidation kind. + /// The reason reported by CheatEngine.SDK. + /// + /// The Client kind; for an unrecognized value and for + /// , which only the experimental stop request that the + /// Client never makes produces. + /// + internal static ValueScanInvalidationKind ToInvalidation(MemoryScanInvalidationReason reason) + { + return reason switch + { + MemoryScanInvalidationReason.None => ValueScanInvalidationKind.None, + MemoryScanInvalidationReason.ProtectedLuaFailure => ValueScanInvalidationKind.HostCallFailed, + MemoryScanInvalidationReason.RuntimeIdentityChanged => ValueScanInvalidationKind.RuntimeChanged, + MemoryScanInvalidationReason.TargetChanged => ValueScanInvalidationKind.TargetChanged, + // A reused PID names another process incarnation: for the Client that is a change of target. + MemoryScanInvalidationReason.TargetProcessReused => ValueScanInvalidationKind.TargetChanged, + MemoryScanInvalidationReason.ScanTerminated => ValueScanInvalidationKind.Unknown, + _ => ValueScanInvalidationKind.Unknown + }; + } + + /// Maps the cancellation milestone of a cancellable SDK operation to a cancellation failure. + /// The public Client operation name. + /// Where CheatEngine.SDK observed the cancellation. + /// + /// A failure: + /// before the native call, after it returned, otherwise + /// . + /// + internal static CheatEngineFailure Cancelled(string operation, MemoryScanCancellationMilestone milestone) + { + return milestone switch + { + MemoryScanCancellationMilestone.CancelledBeforeNativeCall => CancellationMapping.BeforeNativeCall(operation), + MemoryScanCancellationMilestone.ObservedAfterNativeCall => CancellationMapping.AfterNativeCall(operation), + MemoryScanCancellationMilestone.None => Failure(CheatEngineFailureKind.Cancelled, operation, + CheatEngineHostEffect.Unknown, + "The operation was cancelled and CheatEngine.SDK recorded no cancellation milestone."), + _ => Failure(CheatEngineFailureKind.Cancelled, operation, CheatEngineHostEffect.Unknown, + "The operation was cancelled at a point this Client version does not recognize.") + }; + } + + /// Maps the status of a result-page copy to its failure. + /// The status of TryCopyResultsPageCancellable. + /// The cancellation milestone of the copy. + /// The public Client operation name. + /// The failure, or the default value for a page (a copied page or an empty result set). + /// when the status is a failure. + internal static bool TryGetPageFailure(MemoryScanMaterializationStatus status, + MemoryScanCancellationMilestone milestone, string operation, out CheatEngineFailure failure) + { + failure = status switch + { + MemoryScanMaterializationStatus.Success or MemoryScanMaterializationStatus.NoResults => default, + MemoryScanMaterializationStatus.DestinationTooSmall => Failure(CheatEngineFailureKind.ResultLimitExceeded, + operation, CheatEngineHostEffect.Completed, + "The result page did not fit the Client's copy buffer; no result was copied."), + MemoryScanMaterializationStatus.Cancelled => Cancelled(operation, milestone), + MemoryScanMaterializationStatus.RuntimeInvalidated => Failure(CheatEngineFailureKind.RuntimeChanged, + operation, CheatEngineHostEffect.NotStarted, + "The scan session belongs to an earlier Lua runtime; only its release remains."), + MemoryScanMaterializationStatus.TargetIdentityUnavailable => Failure( + CheatEngineFailureKind.TargetIdentityUnavailable, operation, CheatEngineHostEffect.NotStarted, + "The identity of Cheat Engine's selected target could not be established; no result was read."), + MemoryScanMaterializationStatus.TargetIdentityMismatch => Failure(CheatEngineFailureKind.TargetChanged, + operation, CheatEngineHostEffect.NotStarted, + "Cheat Engine selected another target than the session's; only its release remains."), + MemoryScanMaterializationStatus.LuaFailure => Failure(CheatEngineFailureKind.LuaError, operation, + CheatEngineHostEffect.Unknown, "A Cheat Engine result call raised a Lua error; no result was copied."), + MemoryScanMaterializationStatus.InvalidResult => Failure(CheatEngineFailureKind.InvalidHostResult, + operation, CheatEngineHostEffect.Completed, + "Cheat Engine returned a result count, address or value outside its documented shape; no result was " + + "copied."), + MemoryScanMaterializationStatus.PageStartOutOfRange => Failure(CheatEngineFailureKind.OperationRejected, + operation, CheatEngineHostEffect.Completed, + "The page starts at or beyond the number of results; no result was copied."), + _ => Failure(CheatEngineFailureKind.IndeterminateHostResult, operation, CheatEngineHostEffect.Unknown, + "CheatEngine.SDK reported no recognized result-copy status; no result was copied.") + }; + return status is not (MemoryScanMaterializationStatus.Success or MemoryScanMaterializationStatus.NoResults); + } + + /// Combines the release status of the found list and of the scanner, keeping the worse. + /// The SDK release statuses. + /// The lease outcome. + /// + /// Both objects confirmed as released still leave when a scan + /// that may have been running was not confirmed as stopped (), as the + /// bounded AOB route reports it. A release that could not reach Cheat Engine already reports its refusal in both + /// statuses. + /// + internal static LeaseReleaseOutcome FromRelease(ValueScanReleaseStatuses statuses) + { + LeaseReleaseOutcome released = SdkReleaseOutcomes.Worst(SdkReleaseOutcomes.FromTarget(statuses.FoundList), + SdkReleaseOutcomes.FromTarget(statuses.MemScan)); + return released.Kind == LeaseReleaseKind.Released && !ScanTermination.IsStopConfirmed(statuses.Termination) + ? SdkReleaseOutcomes.Worst(released, + new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started)) + : released; + } + + /// Returns how far a scan, wait or reset got when CheatEngine.SDK threw. + /// The SDK fault. + /// + /// for a refusal that precedes every Cheat Engine call (session state, + /// re-entrant call, request validation, changed runtime or target), for a + /// Cheat Engine call that failed, otherwise . + /// + internal static CheatEngineHostEffect MutationFaultEffect(Exception fault) + { + return fault switch + { + MemoryScanStateException or ArgumentException => CheatEngineHostEffect.NotStarted, + MemoryScanException scan => MutationFaultEffect(scan.FailureKind), + _ => CheatEngineHostEffect.Unknown + }; + } + + /// Returns how far a scan, wait or reset got for an SDK memory-scan failure category. + /// The category of the . + /// The host effect; for an unrecognized value. + internal static CheatEngineHostEffect MutationFaultEffect(MemoryScanFailureKind kind) + { + return kind switch + { + // The SDK checks the runtime and target context before any Cheat Engine call of an operation. + MemoryScanFailureKind.RuntimeInvalidated or MemoryScanFailureKind.TargetIdentityUnavailable + or MemoryScanFailureKind.TargetIdentityMismatch => CheatEngineHostEffect.NotStarted, + MemoryScanFailureKind.MissingCapability or MemoryScanFailureKind.LuaError + or MemoryScanFailureKind.UnexpectedResult => CheatEngineHostEffect.Started, + _ => CheatEngineHostEffect.Unknown + }; + } + + /// Returns how far a result-count read got when CheatEngine.SDK threw. + /// The SDK fault. + /// + /// for a refusal that precedes the Cheat Engine call, + /// for a count Cheat Engine returned in a malformed shape, otherwise + /// . + /// + internal static CheatEngineHostEffect ReadFaultEffect(Exception fault) + { + return fault switch + { + MemoryScanStateException or ArgumentException => CheatEngineHostEffect.NotStarted, + MemoryScanException { FailureKind: MemoryScanFailureKind.UnexpectedResult } => + CheatEngineHostEffect.Completed, + MemoryScanException scan when MutationFaultEffect(scan.FailureKind) == CheatEngineHostEffect.NotStarted => + CheatEngineHostEffect.NotStarted, + _ => CheatEngineHostEffect.Unknown + }; + } + + /// Creates the refusal of an operation on a released session. + /// The public Client operation name. + /// The outcome of the release that ended the session, when the lease recorded one. + /// + /// , + /// or when the release was refused for that reason, otherwise + /// ; always . + /// + /// + /// CheatEngine.SDK 2.0.0 reports a session released after a change of the Lua runtime as NotInvoked + /// (), so such a session reports + /// ; the arm + /// keeps the mapping total over the lease vocabulary. + /// + internal static CheatEngineFailure Released(string operation, LeaseReleaseOutcome? outcome) + { + CheatEngineFailureKind kind = outcome?.Kind switch + { + LeaseReleaseKind.RefusedTargetChanged => CheatEngineFailureKind.TargetChanged, + LeaseReleaseKind.RefusedTargetIdentityUnavailable => CheatEngineFailureKind.TargetIdentityUnavailable, + LeaseReleaseKind.RefusedRuntimeChanged => CheatEngineFailureKind.RuntimeChanged, + _ => CheatEngineFailureKind.InvalidState + }; + return Failure(kind, operation, CheatEngineHostEffect.NotStarted, + "The value-scan session was released; create a new session."); + } + + /// Appends Cheat Engine's own error text of the scan to a failure message. + /// The failure. + /// Cheat Engine's bounded error text, or . + /// Whether CheatEngine.SDK truncated the text. + /// The failure with the text appended, or when there is no text. + internal static CheatEngineFailure WithHostErrorText(CheatEngineFailure failure, string? hostErrorText, + bool truncated) + { + if (string.IsNullOrWhiteSpace(hostErrorText)) + { + return failure; + } + + string message = failure.Message + " Cheat Engine reported: " + hostErrorText.Trim() + + (truncated ? " (truncated)" : string.Empty); + return new CheatEngineFailure(failure.Kind, failure.Operation, message, failure.Exception, failure.HostEffect); + } + + private static CheatEngineFailure Failure(CheatEngineFailureKind kind, string operation, + CheatEngineHostEffect hostEffect, string message) + { + return new CheatEngineFailure(kind, operation, message, null, hostEffect); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanRequests.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanRequests.cs new file mode 100644 index 0000000..94ac6f4 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanRequests.cs @@ -0,0 +1,282 @@ +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Domains.ValueScanning; + +/// Validates Client value-scan requests and builds the positional CheatEngine.SDK requests from them. +/// +/// A request that its factories would refuse (the request, or a tampered one) throws an +/// before the activation check and before any Cheat Engine work is dispatched. +/// The one refusal returned as a failure is a next-scan value of another type than the session's first scan: +/// with . +/// The protection filter and the alignment rule are written by , the +/// translation the AOB options use; without alignment the positional SDK request carries an empty parameter. +/// +internal static class ValueScanRequests +{ + /// Validates a first-scan request and builds the SDK first-scan request from it. + /// The Client request. + /// The SDK request. + /// + /// The request has no value (the request) or a value its comparison refuses. + /// + /// + /// Its comparison, its value type or the type of a value it carries is not a defined value, its range is empty, + /// or its protection filter or alignment rule is not a defined value. + /// + internal static FirstScanRequest CreateFirst(ValueScanFirstRequest request) + { + ValidateFirst(request); + ScanValueFlags flags = GetFlags(request.ValueType); + (FastScanMethod alignmentMethod, string? alignmentParameter) = + ScanOptionTranslation.ToFastScan(request.Alignment, string.Empty); + return new FirstScanRequest( + ToScanOption(request.Comparison), + flags.VariableType, + RoundingType.Rounded, + request.Value?.Text ?? string.Empty, + request.UpperValue?.Text ?? string.Empty, + request.StartAddress, + request.StopAddress, + ScanOptionTranslation.ToProtectionText(request.Protection), + alignmentMethod, + alignmentParameter ?? string.Empty, + flags.IsHexadecimalInput, + false, + flags.IsUnicodeScan, + flags.IsCaseSensitive); + } + + /// Throws for a next-scan request that its factories would refuse, whatever the session. + /// The Client request. + /// + /// The request has no value (the request), a value its comparison refuses, or bounds + /// of two types. + /// + /// + /// Its comparison, or the type of a value it carries, is not a defined value. + /// + internal static void ValidateNext(ValueScanNextRequest request) + { + if (!Enum.IsDefined(request.Comparison)) + { + throw new ArgumentOutOfRangeException(nameof(request), request.Comparison, + "A next value scan request has a comparison that is not a defined value."); + } + + ValidateValueType(request.Value, nameof(request)); + ValidateValueType(request.UpperValue, nameof(request)); + + string? problem = request.Comparison switch + { + ValueScanComparison.Exact => ValidateValue(request.Value, requireNumeric: false), + ValueScanComparison.BiggerThan or ValueScanComparison.SmallerThan or ValueScanComparison.IncreasedBy + or ValueScanComparison.DecreasedBy => ValidateValue(request.Value, requireNumeric: true), + ValueScanComparison.Between => ValidateValue(request.Value, requireNumeric: true) ?? + ValidateValue(request.UpperValue, requireNumeric: true) ?? + ValidateSameType(request.Value, request.UpperValue), + ValueScanComparison.Increased or ValueScanComparison.Decreased or ValueScanComparison.Changed + or ValueScanComparison.Unchanged => request.Value is null && request.UpperValue is null + ? null + : "A comparison with the previous scan carries no value.", + _ => "A next value scan compares a value, a range, a bound or the previous scan." + }; + if (problem is not null) + { + throw new ArgumentException(problem, nameof(request)); + } + } + + /// + /// Builds the SDK next-scan request of a validated request () for a session whose + /// first scan compared . + /// + /// The validated Client request. + /// The value type of the session's first scan, or without one. + /// The public Client operation name. + /// The SDK request when the method returns . + /// The refusal when the method returns . + /// + /// unless the request carries a value of another type than the session's. + /// + /// + /// Without a first scan the request is built with the type of its value, or with integer flags: CheatEngine.SDK + /// then refuses the next scan by the session state before any Cheat Engine call. + /// + internal static bool TryCreateNext(ValueScanNextRequest request, ValueScanValueType? valueType, string operation, + out NextScanRequest sdkRequest, out CheatEngineFailure failure) + { + sdkRequest = default; + if (valueType is { } expected && request.Value is { } value && value.ValueType != expected) + { + // Both bounds of a validated range have the same type: the lower one stands for the pair. + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + $"The scanned value is a {value.ValueType} value; the session scans {expected} values.", null, + CheatEngineHostEffect.NotStarted); + return false; + } + + ScanValueFlags flags = GetFlags(valueType ?? request.Value?.ValueType ?? ValueScanValueType.Integer32); + sdkRequest = new NextScanRequest( + ToScanOption(request.Comparison), + RoundingType.Rounded, + request.Value?.Text ?? string.Empty, + request.UpperValue?.Text ?? string.Empty, + flags.IsHexadecimalInput, + false, + flags.IsUnicodeScan, + flags.IsCaseSensitive, + false); + failure = default; + return true; + } + + /// Maps a Client comparison to Cheat Engine's scan option. + /// A defined comparison. + /// The scan option. + /// is not defined. + internal static ScanOption ToScanOption(ValueScanComparison comparison) + { + return comparison switch + { + ValueScanComparison.Exact => ScanOption.ExactValue, + ValueScanComparison.Between => ScanOption.ValueBetween, + ValueScanComparison.BiggerThan => ScanOption.BiggerThan, + ValueScanComparison.SmallerThan => ScanOption.SmallerThan, + ValueScanComparison.UnknownInitialValue => ScanOption.UnknownValue, + ValueScanComparison.Increased => ScanOption.IncreasedValue, + ValueScanComparison.IncreasedBy => ScanOption.IncreasedValueBy, + ValueScanComparison.Decreased => ScanOption.DecreasedValue, + ValueScanComparison.DecreasedBy => ScanOption.DecreasedValueBy, + ValueScanComparison.Changed => ScanOption.Changed, + ValueScanComparison.Unchanged => ScanOption.Unchanged, + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "The value-scan comparison is not defined.") + }; + } + + /// Maps a Client value type to Cheat Engine's variable type and input flags. + /// A defined value type. + /// The Cheat Engine variable type and the input flags of the scan. + /// is not defined. + internal static ScanValueFlags GetFlags(ValueScanValueType valueType) + { + return valueType switch + { + ValueScanValueType.Integer8 => new ScanValueFlags(VariableType.Byte, false, false, false), + ValueScanValueType.Integer16 => new ScanValueFlags(VariableType.Word, false, false, false), + ValueScanValueType.Integer32 => new ScanValueFlags(VariableType.Dword, false, false, false), + ValueScanValueType.Integer64 => new ScanValueFlags(VariableType.Qword, false, false, false), + ValueScanValueType.SingleFloat => new ScanValueFlags(VariableType.Single, false, false, false), + ValueScanValueType.DoubleFloat => new ScanValueFlags(VariableType.Double, false, false, false), + ValueScanValueType.Utf8String => new ScanValueFlags(VariableType.String, false, false, true), + ValueScanValueType.Utf16String => new ScanValueFlags(VariableType.String, false, true, true), + ValueScanValueType.ByteArray => new ScanValueFlags(VariableType.ByteArray, true, false, false), + _ => throw new ArgumentOutOfRangeException(nameof(valueType), valueType, + "The value-scan value type is not defined.") + }; + } + + private static void ValidateFirst(ValueScanFirstRequest request) + { + if (!Enum.IsDefined(request.Comparison) || !Enum.IsDefined(request.ValueType)) + { + throw new ArgumentOutOfRangeException(nameof(request), + "A first value scan request has a comparison or a value type that is not a defined value."); + } + + ValidateValueType(request.Value, nameof(request)); + ValidateValueType(request.UpperValue, nameof(request)); + + string? problem = request.Comparison switch + { + ValueScanComparison.Exact => ValidateValue(request.Value, request.ValueType, requireNumeric: false), + ValueScanComparison.BiggerThan or ValueScanComparison.SmallerThan => ValidateValue(request.Value, + request.ValueType, requireNumeric: true), + ValueScanComparison.Between => ValidateValue(request.Value, request.ValueType, requireNumeric: true) ?? + ValidateValue(request.UpperValue, request.ValueType, requireNumeric: true), + ValueScanComparison.UnknownInitialValue => IsNumeric(request.ValueType) && request.Value is null + ? null + : "An unknown initial value scan requires a numeric value type and no value.", + _ => "A first value scan compares an exact value, a range, a bound or an unknown initial value." + }; + if (problem is not null) + { + throw new ArgumentException(problem, nameof(request)); + } + + bool nonEmptyRange = request.StopAddress > request.StartAddress; + if (!nonEmptyRange) + { + throw new ArgumentOutOfRangeException(nameof(request), request.StopAddress, + "A value scan range must be non-empty: the exclusive stop address must be greater than the start " + + "address."); + } + + if (!ScanOptionTranslation.IsDefined(request.Protection)) + { + throw new ArgumentOutOfRangeException(nameof(request), + "A value scan protection filter must use defined requirements."); + } + + if (!ScanOptionTranslation.IsDefined(request.Alignment)) + { + throw new ArgumentOutOfRangeException(nameof(request), + "A value scan alignment must be created by a ScanAlignment factory."); + } + } + + /// + /// Throws for a value whose type no factory creates (a tampered value), before + /// the comparison rules, which would report it as a type mismatch or leave it to Cheat Engine's main thread. + /// + /// The value's type is not a defined value. + private static void ValidateValueType(ValueScanValue? value, string parameterName) + { + if (value is { } present && !Enum.IsDefined(present.ValueType)) + { + throw new ArgumentOutOfRangeException(parameterName, present.ValueType, + "A value scan request carries a value whose type is not a defined value."); + } + } + + private static string? ValidateValue(ValueScanValue? value, ValueScanValueType expected, bool requireNumeric) + { + return ValidateValue(value, requireNumeric) ?? (value!.Value.ValueType == expected + ? null + : $"The scanned value is a {value.Value.ValueType} value; the request scans {expected} values."); + } + + private static string? ValidateValue(ValueScanValue? value, bool requireNumeric) + { + if (value is not { Text: not null } present) + { + return "A value scan comparison requires a value created by a ValueScanValue factory."; + } + + return requireNumeric && !IsNumeric(present.ValueType) ? "This comparison accepts only a numeric value." : null; + } + + private static string? ValidateSameType(ValueScanValue? lowest, ValueScanValue? highest) + { + return lowest?.ValueType == highest?.ValueType ? null : "Both bounds of a value scan must have the same type."; + } + + private static bool IsNumeric(ValueScanValueType valueType) + { + return valueType is >= ValueScanValueType.Integer8 and <= ValueScanValueType.DoubleFloat; + } + + /// Cheat Engine's variable type and the input flags of one scanned value type. + /// The Cheat Engine variable type. + /// Whether Cheat Engine reads the input as hexadecimal bytes. + /// Whether a string scan uses UTF-16. + /// Whether a string scan matches case. + internal readonly record struct ScanValueFlags( + VariableType VariableType, + bool IsHexadecimalInput, + bool IsUnicodeScan, + bool IsCaseSensitive); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanSession.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanSession.cs new file mode 100644 index 0000000..0885086 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanSession.cs @@ -0,0 +1,445 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Domains.ValueScanning; + +/// One value-scan session: a lease over one CheatEngine.SDK MemoryScanSession. +/// +/// +/// Every operation runs on Cheat Engine's main thread through the activation dispatcher and touches the SDK session +/// only there. A first or next scan starts Cheat Engine's scan and waits for it in the same callback +/// (Start*Cancellable, then WaitForCompletionCancellable), so the results are initialized before the +/// call returns. The Client state is the SDK state copied after each operation, so reading it never touches the +/// SDK session. +/// +/// +/// The session is a registered with the activation and with its target selection: +/// the release runs ReleaseWithOutcome (found list, then scanner) on the main thread, when the application +/// releases it, when the Client observes that Cheat Engine selected another process, or before CheatEngine.SDK +/// detaches at deactivation. The release that follows a target change is refused by CheatEngine.SDK without any +/// Cheat Engine call, which consumes both owners and leaves the two objects in Cheat Engine. +/// +/// +internal sealed class ValueScanSession : HostResourceLease, IValueScanSession +{ + /// The public operation name of the release. + internal const string ReleaseOperation = "ValueScans.Release"; + + /// The public operation name of a first scan. + internal const string FirstScanOperation = "ValueScans.FirstScan"; + + /// The public operation name of a next scan. + internal const string NextScanOperation = "ValueScans.NextScan"; + + /// The public operation name of a reset. + internal const string ResetOperation = "ValueScans.Reset"; + + /// The public operation name of a result count. + internal const string ResultCountOperation = "ValueScans.GetResultCount"; + + /// The public operation name of a result read. + internal const string ReadOperation = "ValueScans.Read"; + + private const string ScanNotAwaitedMessage = + "The operation was cancelled after Cheat Engine started the scan and before the Client waited for it: the " + + "session keeps scanning until it is released."; + + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly IValueScanSessionHandle _handle; + private int _invalidation = (int) ValueScanInvalidationKind.None; + private int _state = (int) ValueScanSessionState.Created; + + // The value type of the scan whose results are ready, read and written on the main thread only. + private ValueScanValueType? _valueType; + + /// Creates the lease of a created SDK session; the caller registers it. + /// The activation dispatcher. + /// The SDK session, which this lease owns from now on. + /// The target-selection epoch of the process the session was created in. + internal ValueScanSession(SdkMainThreadDispatcher dispatcher, IValueScanSessionHandle handle, long selectionEpoch) + : base(ReleaseOperation, dispatcher, dispatcher?.Lifetime.Diagnostics) + { + _dispatcher = dispatcher!; + _handle = handle ?? throw new ArgumentNullException(nameof(handle)); + SelectionEpoch = selectionEpoch; + } + + public long SelectionEpoch + { + get; + } + + public ValueScanSessionState State => + IsReleased ? ValueScanSessionState.Closed : (ValueScanSessionState) Volatile.Read(ref _state); + + public ValueScanInvalidationKind Invalidation => (ValueScanInvalidationKind) Volatile.Read(ref _invalidation); + + public bool TryFirstScan(ValueScanFirstRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + // Arguments first, then the activation (TryRun), then the refusals. + FirstScanRequest sdkRequest = ValueScanRequests.CreateFirst(request); + return TryRun(FirstScanOperation, + token => FirstScanOnMainThread(sdkRequest, request.ValueType, token), out failure, cancellationToken); + } + + public void FirstScan(ValueScanFirstRequest request, CancellationToken cancellationToken = default) + { + if (!TryFirstScan(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + public bool TryNextScan(ValueScanNextRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + // Arguments first; a value of another type than the session's first scan is refused on the main thread. + ValueScanRequests.ValidateNext(request); + return TryRun(NextScanOperation, token => NextScanOnMainThread(request, token), out failure, + cancellationToken); + } + + public void NextScan(ValueScanNextRequest request, CancellationToken cancellationToken = default) + { + if (!TryNextScan(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + public bool TryReset(out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryRun(ResetOperation, ResetOnMainThread, out failure, cancellationToken); + } + + public void Reset(CancellationToken cancellationToken = default) + { + if (!TryReset(out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + public bool TryGetResultCount(out ulong resultCount, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ulong count = 0; + bool succeeded = TryRun(ResultCountOperation, token => CountOnMainThread(token, out count), out failure, + cancellationToken); + resultCount = succeeded ? count : 0; + return succeeded; + } + + public ulong GetResultCount(CancellationToken cancellationToken = default) + { + if (TryGetResultCount(out ulong resultCount, out CheatEngineFailure failure, cancellationToken)) + { + return resultCount; + } + + failure.Throw(cancellationToken); + return 0; + } + + public bool TryRead(ValueScanReadRequest request, out ValueScanPage page, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + page = default; + // Arguments first, as the request's constructor checks them: the default request allows no result. + if (request.MaximumCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.MaximumCount, + "A value-scan read requires a positive maximum count; the default request has none."); + } + + if (request.StartIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.StartIndex, + "A value-scan read requires a non-negative start index."); + } + + // An ended or stopping activation throws before a refusal is reported, never the reverse. + _dispatcher.Lifetime.ThrowIfDispatchRefused(ReadOperation); + if (request.StartIndex > int.MaxValue) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, ReadOperation, + "Cheat Engine addresses scan results with a 32-bit index; the page starts beyond it.", null, + CheatEngineHostEffect.NotStarted); + return false; + } + + ValueScanPage read = default; + bool succeeded = TryRun(ReadOperation, token => ReadOnMainThread(request, token, out read), out failure, + cancellationToken); + page = succeeded ? read : default; + return succeeded; + } + + public ValueScanPage Read(ValueScanReadRequest request, CancellationToken cancellationToken = default) + { + if (TryRead(request, out ValueScanPage page, out CheatEngineFailure failure, cancellationToken)) + { + return page; + } + + failure.Throw(cancellationToken); + return default; + } + + protected override LeaseReleaseOutcome ReleaseOnMainThread() + { + try + { + return ValueScanMapping.FromRelease(_handle.Release()); + } + finally + { + Refresh(); + } + } + + /// + /// Dispatches one operation: when it succeeded, otherwise with its + /// failure. + /// + /// + /// A cancellation observed before dispatch, and a session that was released, are refused without any SDK call. The + /// Client state is copied from the SDK session after every dispatched operation, whatever its result. + /// + private bool TryRun(string operation, Func work, + out CheatEngineFailure failure, CancellationToken cancellationToken) + { + // An ended or stopping activation throws before a cancellation is reported, never the reverse. + _dispatcher.Lifetime.ThrowIfDispatchRefused(operation); + if (cancellationToken.IsCancellationRequested) + { + failure = CancellationMapping.BeforeNativeCall(operation); + return false; + } + + if (!_dispatcher.TryInvoke(() => RunOnMainThread(operation, work, cancellationToken), + out CheatEngineFailure? outcome, out failure, cancellationToken)) + { + return false; + } + + failure = outcome ?? default; + return outcome is null; + } + + private CheatEngineFailure? RunOnMainThread(string operation, Func work, + CancellationToken cancellationToken) + { + if (IsReleased || _handle.State == MemoryScanState.Disposed) + { + Refresh(); + return ValueScanMapping.Released(operation, LastReleaseOutcome); + } + + if (cancellationToken.IsCancellationRequested) + { + return CancellationMapping.BeforeNativeCall(operation); + } + + try + { + return work(cancellationToken); + } + finally + { + Refresh(); + } + } + + private CheatEngineFailure? FirstScanOnMainThread(FirstScanRequest request, ValueScanValueType valueType, + CancellationToken cancellationToken) + { + try + { + _handle.StartFirstScan(in request, cancellationToken); + } + catch (OperationCanceledException) + { + return ValueScanMapping.Cancelled(FirstScanOperation, _handle.LastCancellationMilestone); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + return StartFailed(FirstScanOperation, fault); + } + + return WaitForResults(FirstScanOperation, valueType, cancellationToken); + } + + private CheatEngineFailure? NextScanOnMainThread(ValueScanNextRequest request, + CancellationToken cancellationToken) + { + if (!ValueScanRequests.TryCreateNext(request, _valueType, NextScanOperation, out NextScanRequest sdkRequest, + out CheatEngineFailure refusal)) + { + return refusal; + } + + try + { + _handle.StartNextScan(in sdkRequest, cancellationToken); + } + catch (OperationCanceledException) + { + return ValueScanMapping.Cancelled(NextScanOperation, _handle.LastCancellationMilestone); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + return StartFailed(NextScanOperation, fault); + } + + return WaitForResults(NextScanOperation, _valueType ?? request.Value?.ValueType ?? ValueScanValueType.Integer32, + cancellationToken); + } + + /// Waits for a started scan; from here on Cheat Engine's scan has started, whatever happens. + private CheatEngineFailure? WaitForResults(string operation, ValueScanValueType valueType, + CancellationToken cancellationToken) + { + try + { + _handle.WaitForCompletion(cancellationToken); + } + catch (OperationCanceledException) + { + return CancellationMapping.BetweenNativeCalls(operation, ScanNotAwaitedMessage); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + return WithHostErrorText(SdkBoundary.Translate(operation, fault, CheatEngineHostEffect.Started, + _dispatcher.Lifetime)); + } + + _valueType = valueType; + return cancellationToken.IsCancellationRequested ? CancellationMapping.AfterNativeCall(operation) : null; + } + + private CheatEngineFailure? ResetOnMainThread(CancellationToken cancellationToken) + { + try + { + _handle.Reset(cancellationToken); + } + catch (OperationCanceledException) + { + return ValueScanMapping.Cancelled(ResetOperation, _handle.LastCancellationMilestone); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + return SdkBoundary.Translate(ResetOperation, fault, ValueScanMapping.MutationFaultEffect(fault), + _dispatcher.Lifetime); + } + + _valueType = null; + return cancellationToken.IsCancellationRequested ? CancellationMapping.AfterNativeCall(ResetOperation) : null; + } + + private CheatEngineFailure? CountOnMainThread(CancellationToken cancellationToken, out ulong count) + { + count = 0; + try + { + count = _handle.ReadResultCount(); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + return SdkBoundary.Translate(ResultCountOperation, fault, ValueScanMapping.ReadFaultEffect(fault), + _dispatcher.Lifetime); + } + + return cancellationToken.IsCancellationRequested + ? CancellationMapping.AfterNativeCall(ResultCountOperation) + : null; + } + + private CheatEngineFailure? ReadOnMainThread(ValueScanReadRequest request, CancellationToken cancellationToken, + out ValueScanPage page) + { + page = default; + int capacity = Math.Min(request.MaximumCount, ScanResourceLimits.MaximumValueScanPage); + MemoryScanResult[] buffer = new MemoryScanResult[capacity]; + MemoryScanMaterializationStatus status; + ulong totalCount; + int written; + try + { + status = _handle.TryCopyResultsPage((int) request.StartIndex, buffer, out totalCount, out written, + cancellationToken); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + return SdkBoundary.Translate(ReadOperation, fault, ValueScanMapping.ReadFaultEffect(fault), + _dispatcher.Lifetime); + } + + if (ValueScanMapping.TryGetPageFailure(status, _handle.LastCancellationMilestone, ReadOperation, + out CheatEngineFailure failure)) + { + return failure; + } + + if (status == MemoryScanMaterializationStatus.NoResults) + { + page = new ValueScanPage(request.StartIndex, 0, []); + return cancellationToken.IsCancellationRequested ? CancellationMapping.AfterNativeCall(ReadOperation) : null; + } + + if (written < 0 || written > capacity || Array.Exists(buffer[..written], static row => row.Value is null)) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ReadOperation, + "CheatEngine.SDK reported a copied result page outside its documented shape; no result was copied.", null, + CheatEngineHostEffect.Completed); + } + + if (cancellationToken.IsCancellationRequested) + { + return CancellationMapping.AfterNativeCall(ReadOperation); + } + + ValueScanMatch[] matches = new ValueScanMatch[written]; + for (int index = 0; index < written; index++) + { + matches[index] = new ValueScanMatch(buffer[index].Address, buffer[index].Value); + } + + page = new ValueScanPage(request.StartIndex, totalCount, [.. matches]); + return null; + } + + private CheatEngineFailure StartFailed(string operation, Exception fault) + { + CheatEngineFailure failure = SdkBoundary.Translate(operation, fault, ValueScanMapping.MutationFaultEffect(fault), + _dispatcher.Lifetime); + return failure.HostEffect == CheatEngineHostEffect.NotStarted ? failure : WithHostErrorText(failure); + } + + /// Appends Cheat Engine's bounded error text of the scan, when the session can still read it. + private CheatEngineFailure WithHostErrorText(CheatEngineFailure failure) + { + try + { + return _handle.TryGetHostErrorText(out string? text, out bool truncated) + ? ValueScanMapping.WithHostErrorText(failure, text, truncated) + : failure; + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + // The text is a diagnostic fact only: a session that can no longer read it keeps the failure as it is. + return failure; + } + } + + private void Refresh() + { + Volatile.Write(ref _state, (int) ValueScanMapping.ToState(_handle.State)); + Volatile.Write(ref _invalidation, (int) ValueScanMapping.ToInvalidation(_handle.InvalidationReason)); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanner.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanner.cs new file mode 100644 index 0000000..0d7b6c1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanning/ValueScanner.cs @@ -0,0 +1,172 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Domains.ValueScanning; + +/// Creates value-scan sessions through CheatEngine.SDK's scan-session factory, on Cheat Engine's main thread. +/// +/// +/// A created session is registered with the activation and with the target selection it was created for, in the +/// same main-thread callback that created it, so no path leaves its Cheat Engine objects without an owner: a +/// registration that fails, and a cancellation observed after the creation, release them at once. An ended or +/// stopping activation is refused in that callback before Cheat Engine creates anything, since no lease could own +/// it; a registration refused after the creation reports the release (). +/// +/// +/// The target selection is the one of the process incarnation that CheatEngine.SDK bound the session to +/// (), not the last selection the Client observed: a process selected in Cheat +/// Engine's own window since then advances the epoch before the session is registered, so the next observation +/// never releases a session whose own process is still selected. +/// +/// +internal sealed class ValueScanner : IValueScanner +{ + /// The public operation name of a session creation. + internal const string CreateOperation = "ValueScans.CreateSession"; + + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly IValueScanPort _port; + private readonly ITargetSelectionBinder _selection; + + /// Creates the value scanner of an activation. + /// The activation dispatcher. + /// The owner of the observed target selection, the activation's process client. + /// The session factory; CheatEngine.SDK's when omitted. + internal ValueScanner(SdkMainThreadDispatcher dispatcher, ITargetSelectionBinder selection, + IValueScanPort? port = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _port = port ?? SdkValueScanPort.Instance; + } + + public bool TryCreateSession([NotNullWhen(true)] out IValueScanSession? session, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + session = null; + // Creating a lease-owned resource is admitted only while the activation is active, never from the cleanup scope: + // an ended or stopping activation throws before a cancellation is reported, never the reverse. + _dispatcher.Lifetime.ThrowIfInactive(CreateOperation); + if (cancellationToken.IsCancellationRequested) + { + failure = CancellationMapping.BeforeNativeCall(CreateOperation); + return false; + } + + if (!_dispatcher.TryInvoke(() => CreateOnMainThread(cancellationToken), out CreateOutcome outcome, + out failure, cancellationToken)) + { + return false; + } + + _selection.ReportBinding(outcome.Binding, CreateOperation); + session = outcome.Session; + failure = outcome.Failure; + return session is not null; + } + + public IValueScanSession CreateSession(CancellationToken cancellationToken = default) + { + if (TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, cancellationToken)) + { + return session; + } + + failure.Throw(cancellationToken); + throw new UnreachableException(); + } + + private CreateOutcome CreateOnMainThread(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return new CreateOutcome(null, CancellationMapping.BeforeNativeCall(CreateOperation)); + } + + CoreLifetime lifetime = _dispatcher.Lifetime; + // No lease can be registered once the activation stops or ends (a deactivation cleanup scope included): refuse + // before Cheat Engine creates objects that no lease could own. + lifetime.ThrowIfInactive(CreateOperation); + MemoryScanCreationStatus status; + IValueScanSessionHandle? handle; + try + { + status = _port.TryCreate(out handle); + } + catch (Exception fault) when (SdkBoundary.IsSdkFault(fault)) + { + // The SDK rolls back what it created before it throws; what the host did is not known. + return new CreateOutcome(null, + SdkBoundary.Translate(CreateOperation, fault, CheatEngineHostEffect.Unknown, lifetime)); + } + + if (status != MemoryScanCreationStatus.Success || handle is null) + { + // A handle next to a failed creation is released at once; a release that is not complete leaves the + // failure's cleanup unconfirmed, as on every other failure path. + CheatEngineFailure failure = ValueScanMapping.FromCreationStatus(status, CreateOperation); + if (handle is not null && !ValueScanMapping.FromRelease(handle.Release()).IsComplete) + { + failure = CoreFailureFactory.WithHostEffect(failure, CheatEngineHostEffect.CleanupUnconfirmed); + } + + return new CreateOutcome(null, failure); + } + + if (cancellationToken.IsCancellationRequested) + { + // Nothing is published after a late cancellation: the new objects are released at once. + LeaseReleaseOutcome released = ValueScanMapping.FromRelease(handle.Release()); + return new CreateOutcome(null, released.IsComplete + ? CancellationMapping.AfterNativeCall(CreateOperation) + : new CheatEngineFailure(CheatEngineFailureKind.Cancelled, CreateOperation, + "The operation was cancelled after Cheat Engine created the scan session, and its release ended " + + $"with {released.Kind}: a scanner may remain in Cheat Engine.", null, + CheatEngineHostEffect.CleanupUnconfirmed)); + } + + TargetSelectionBinding binding = default; + try + { + binding = _selection.BindOwner(handle.TargetIncarnation, CreateOperation); + ValueScanSession session = new(_dispatcher, handle, binding.SelectionEpoch); + session.Register(lifetime, binding.SelectionEpoch); + return new CreateOutcome(session, default) + { + Binding = binding + }; + } + catch (Exception registration) + { + // The activation or the target selection ended while the session was created: release it here, on the + // main thread, since no registry will. + LeaseReleaseOutcome released = ValueScanMapping.FromRelease(handle.Release()); + if (registration is not (CheatEngineClientException or ObjectDisposedException)) + { + throw; + } + + return new CreateOutcome(null, LeaseRegistration.Refused(lifetime, CreateOperation, registration, + released, "the new scan session")) + { + Binding = binding + }; + } + } + + private readonly record struct CreateOutcome(ValueScanSession? Session, CheatEngineFailure Failure) + { + /// Gets the selection binding of a published session, reported after the callback returned. + internal TargetSelectionBinding Binding + { + get; + init; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CancellationMapping.cs b/libs/CheatEngine.Client.Core/Infrastructure/CancellationMapping.cs new file mode 100644 index 0000000..5bf9049 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CancellationMapping.cs @@ -0,0 +1,92 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// States what a cancellation observed by Core means for the Cheat Engine side effect. +/// +/// +/// A never interrupts a Cheat Engine call. Core observes it at three kinds of +/// point only, and the point alone decides the reported effect: +/// +/// +/// +/// +/// before the native call: with +/// , because nothing was invoked; +/// +/// +/// +/// +/// after the native call returned: with +/// , because Cheat Engine finished its work. The caller +/// discards what it copied and publishes nothing: no partial result, no prefix, no owner. +/// +/// +/// +/// +/// between two native calls of one operation, after the first began work that the second would have +/// completed (a value scan that Cheat Engine started and the Client has not waited for yet): +/// with , +/// because that work may still be running. The caller says in the message what remains and how it ends. +/// +/// +/// +/// +internal static class CancellationMapping +{ + /// The default message of a cancellation observed before the native call. + internal const string BeforeNativeCallMessage = "The operation was cancelled before Cheat Engine work began."; + + /// The default message of a cancellation observed after the native call returned. + internal const string AfterNativeCallMessage = + "The operation was cancelled after Cheat Engine completed its work; no result was published."; + + /// The default message of a cancellation observed between two Cheat Engine calls of one operation. + internal const string BetweenNativeCallsMessage = + "The operation was cancelled after Cheat Engine began its work and before the Client completed it; no result " + + "was published."; + + /// Creates the failure of a cancellation observed before any Cheat Engine call of the operation. + /// The public Client operation name. + /// The diagnostic message. + /// + /// A failure whose effect is + /// . + /// + internal static CheatEngineFailure BeforeNativeCall(string operation, string message = BeforeNativeCallMessage) + { + return new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, message, null, + CheatEngineHostEffect.NotStarted); + } + + /// Creates the failure of a cancellation observed after the operation's Cheat Engine call returned. + /// The public Client operation name. + /// The diagnostic message. + /// + /// A failure whose effect is + /// . + /// + /// The caller publishes nothing it copied from the completed call. + internal static CheatEngineFailure AfterNativeCall(string operation, string message = AfterNativeCallMessage) + { + return new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, message, null, + CheatEngineHostEffect.Completed); + } + + /// + /// Creates the failure of a cancellation observed after one Cheat Engine call of the operation began work and + /// before the call that would complete it. + /// + /// The public Client operation name. + /// The diagnostic message, which says what remains and how it ends. + /// + /// A failure whose effect is + /// . + /// + /// The work the first call began may still be running in Cheat Engine; nothing is published. + internal static CheatEngineFailure BetweenNativeCalls(string operation, string message = BetweenNativeCallsMessage) + { + return new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, message, null, + CheatEngineHostEffect.Started); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ClientExceptions.cs b/libs/CheatEngine.Client.Core/Infrastructure/ClientExceptions.cs new file mode 100644 index 0000000..e4a5599 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ClientExceptions.cs @@ -0,0 +1,39 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Creates the Client exceptions that Core throws, always through the failure they carry. +/// +/// No Client exception has a public constructor: every one is created by +/// , so its type follows the failure kind and it keeps +/// the failure's . An admission or lifetime refusal happens before any +/// Cheat Engine work, so its effect is unless the caller knows more. +/// +internal static class ClientExceptions +{ + /// Creates the exception of an failure. + /// The public operation name. + /// The diagnostic message. + /// The originating exception, if any. + /// How far Cheat Engine work got; nothing started by default. + /// A . + internal static Exception InvalidState(string operation, string message, Exception? exception = null, + CheatEngineHostEffect hostEffect = CheatEngineHostEffect.NotStarted) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, message, exception, hostEffect) + .ToException(); + } + + /// Creates the exception of an failure. + /// The public operation name. + /// The diagnostic message. + /// The originating exception, if any. + /// How far Cheat Engine work got; nothing started by default. + /// A . + internal static Exception ActivationExpired(string operation, string message, Exception? exception = null, + CheatEngineHostEffect hostEffect = CheatEngineHostEffect.NotStarted) + { + return new CheatEngineFailure(CheatEngineFailureKind.ActivationExpired, operation, message, exception, + hostEffect).ToException(); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs b/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs deleted file mode 100644 index 4839b35..0000000 --- a/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using CheatEngine.SDK.Annotations.Lua; - -namespace CheatEngine.Client.Core.Infrastructure; - -/// Internal generated bindings for CE globals that the SDK does not expose as high-level services. -internal static partial class ClientLuaGlobals -{ - [LuaGlobal("getOpenedProcessID")] - internal static partial long GetOpenedProcessId(); - - [LuaGlobal("openProcess")] - internal static partial void OpenProcess(long processId); - - [LuaGlobal("getCEVersion")] - internal static partial double GetCheatEngineVersion(); - - [LuaGlobal("getSystemArchitecture")] - internal static partial int GetSystemArchitecture(); - - [LuaGlobal("getABI")] - internal static partial int GetTargetAbi(); - - [LuaGlobal("targetIs64Bit")] - internal static partial bool TargetIs64Bit(); - - [LuaGlobal("loadTable")] - internal static partial void LoadTable(string path, bool merge); - - [LuaGlobal("saveTable")] - internal static partial void SaveTable(string path); - - [LuaGlobal("getNameFromAddress")] - internal static partial bool TryGetNameFromAddress(nuint address, [MaybeNullWhen(false)] out string? name); - - [LuaGlobal("registerSymbol")] - internal static partial void RegisterSymbol(string name, nuint address, bool doNotSave); - - [LuaGlobal("unregisterSymbol")] - internal static partial void UnregisterSymbol(string name); -} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs b/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs new file mode 100644 index 0000000..8d976d7 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs @@ -0,0 +1,412 @@ +using System.Globalization; +using System.Reflection; + +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// The consumed CheatEngine.SDK identity embedded in this Client build, compared with the CheatEngine.SDK.Engine +/// assembly actually loaded (audit ADR-09, ADR-10, A10-02, A21-35, A22-21). +/// +/// +/// +/// The build embeds, as values of this assembly, the identity of the +/// CheatEngine.SDK package it restored: the version and NuGet content hash of the resolved entry of Core's +/// packages.lock.json (the version equals the pin of eng/CheatEngineSdk.props, or the build fails +/// with CHEATENGINECLIENT9050), the repository commit declared by the restored package whose content hash +/// equals the lock value, and the one CheatEngine.SDK major this Client supports +/// (_CheatEngineClientSupportedSdkMajor of eng/CheatEngineSdk.props). No version is written in +/// this source. +/// +/// +/// The package gate follows the dependency range the Client packages declare: it is +/// when the SemVer version of the loaded +/// CheatEngine.SDK.Engine informational version (build metadata ignored) has the supported major and is at +/// least the pin, by SemVer precedence, so a prerelease of the pin is below it. The reason and +/// say whether the loaded assembly is exactly the reviewed package +/// ({version}+{sourceCommit}, ) or another release of the supported +/// major. Another major or a version below the pin is . An +/// identity without embedded evidence (a defensive case: a build that cannot embed it fails with +/// CHEATENGINECLIENT9050), an SDK assembly without an informational version and an informational version +/// that is not a semantic version are . +/// +/// +/// Only assembly-level attributes are read (AOT-safe); no file, network, or Lua access happens, so the identity is +/// available during enable without touching Cheat Engine. +/// +/// +internal sealed class ConsumedSdkIdentity +{ + /// The assembly-metadata key of the consumed CheatEngine.SDK version. + internal const string VersionKey = "CheatEngine.Client.ConsumedSdk.Version"; + + /// The assembly-metadata key of the consumed CheatEngine.SDK source commit. + internal const string SourceCommitKey = "CheatEngine.Client.ConsumedSdk.SourceCommit"; + + /// The assembly-metadata key of the consumed CheatEngine.SDK NuGet content hash (SHA-512, base64). + internal const string ContentHashKey = "CheatEngine.Client.ConsumedSdk.ContentHashSha512"; + + /// The assembly-metadata key of the one CheatEngine.SDK major this Client supports. + internal const string SupportedMajorKey = "CheatEngine.Client.ConsumedSdk.SupportedMajor"; + + /// + /// The id of the Cheat Engine host profile that the consumed CheatEngine.SDK 2.0.0 names as qualifiable. It names + /// the supported profile; it is not a Client qualification (the Client tuple stays NotExecuted until a + /// Client receipt exists). + /// + internal const string SupportedHostProfileId = "ce-7.7.0.10621-x64-managed-hostfxr"; + + private const string NotComparedLabel = "not compared"; + + private static readonly Lazy LazyCurrent = + new(ReadCurrent, LazyThreadSafetyMode.ExecutionAndPublication); + + /// Creates an identity from explicit values; tests use it to supply expected and loaded identities. + /// The embedded consumed version, the pin. + /// The embedded source commit of the consumed package. + /// The embedded NuGet content hash of the consumed package. + /// The embedded supported major, as the decimal text of the assembly metadata. + /// The informational version of the loaded CheatEngine.SDK.Engine. + internal ConsumedSdkIdentity(string? version, string? sourceCommit, string? contentHashSha512, + string? supportedMajor, string? loadedInformationalVersion) + { + Version = Normalize(version); + SourceCommit = Normalize(sourceCommit); + ContentHashSha512 = Normalize(contentHashSha512); + SupportedMajor = SemanticVersion.TryParseNumericIdentifier(Normalize(supportedMajor), out int major) + ? major + : null; + LoadedInformationalVersion = Normalize(loadedInformationalVersion); + (PackageGate, IdentityLabel, ExactReviewedIdentity) = Evaluate(); + } + + /// Gets the identity of this build compared with the CheatEngine.SDK.Engine assembly loaded in the process. + internal static ConsumedSdkIdentity Current => LazyCurrent.Value; + + /// Gets an identity without embedded evidence and without a loaded version. + internal static ConsumedSdkIdentity NotEmbedded + { + get; + } = new(null, null, null, null, null); + + /// Gets the embedded consumed CheatEngine.SDK version (the pin), or . + internal string? Version + { + get; + } + + /// Gets the embedded consumed CheatEngine.SDK source commit, or . + internal string? SourceCommit + { + get; + } + + /// Gets the embedded NuGet content hash of the consumed package, or . + internal string? ContentHashSha512 + { + get; + } + + /// Gets the embedded CheatEngine.SDK major this Client supports, or . + internal int? SupportedMajor + { + get; + } + + /// Gets the informational version of the loaded CheatEngine.SDK.Engine assembly, or . + internal string? LoadedInformationalVersion + { + get; + } + + /// Gets whether the version, commit, content hash and supported major were all embedded at build. + internal bool IsEmbedded => Version is not null && SourceCommit is not null && ContentHashSha512 is not null && + SupportedMajor is not null; + + /// + /// Gets the informational version of the reviewed package, or when not embedded. + /// + internal string? ExpectedInformationalVersion => IsEmbedded ? $"{Version}+{SourceCommit}" : null; + + /// + /// Gets whether the loaded CheatEngine.SDK.Engine declares exactly the informational version of the reviewed + /// package this build consumed. The package gate does not require it; it is kept for the qualification gate, + /// because a qualification receipt covers only the exact tuple it was produced with. + /// + internal bool ExactReviewedIdentity + { + get; + } + + /// Gets the package evidence gate shared by every operational Client capability. + internal ClientCapabilityEvidenceGate PackageGate + { + get; + } + + /// + /// Gets a short description of how the loaded CheatEngine.SDK.Engine relates to the consumed package, for the + /// activation log: built from versions only, never from a path. + /// + internal string IdentityLabel + { + get; + } + + private (ClientCapabilityEvidenceGate Gate, string Label, bool Exact) Evaluate() + { + if (!IsEmbedded) + { + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, + "This Client build embeds no consumed CheatEngine.SDK identity, so the runtime snapshot does not " + + "establish the identity of the consumed SDK package artifact."), NotComparedLabel, false); + } + + if (!SemanticVersion.TryParse(Version!, out SemanticVersion pin) || pin.Major != SupportedMajor) + { + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, + $"This Client build embeds the consumed CheatEngine.SDK version '{Version}' with the supported major " + + $"{SupportedMajor}, which is not a pin of that major, so no loaded package can be compared with it."), + NotComparedLabel, false); + } + + if (LoadedInformationalVersion is null) + { + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, + "The loaded CheatEngine.SDK.Engine assembly declares no informational version, so it cannot be " + + $"compared with the package this Client build consumed ({ExpectedInformationalVersion})."), + NotComparedLabel, false); + } + + if (!SemanticVersion.TryParse(LoadedInformationalVersion, out SemanticVersion loaded)) + { + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, + $"The loaded CheatEngine.SDK.Engine informational version '{LoadedInformationalVersion}' is not a " + + "semantic version, so it cannot be compared with the package this Client build consumed " + + $"({ExpectedInformationalVersion})."), NotComparedLabel, false); + } + + string supported = $"{SupportedMajor}.x at or above {Version}"; + if (string.Equals(LoadedInformationalVersion, ExpectedInformationalVersion, StringComparison.Ordinal)) + { + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied, + $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} is exactly the reviewed package this " + + $"Client build consumed (NuGet content hash {ContentHashSha512}, read from the lock file at build time)."), + "the reviewed package", true); + } + + if (loaded.Major == SupportedMajor && SemanticVersion.Compare(loaded, pin) >= 0) + { + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied, + $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} is a release of the supported " + + $"CheatEngine.SDK {supported}; it is another release than the reviewed package this Client build " + + $"consumed ({ExpectedInformationalVersion})."), $"another release of {supported}", false); + } + + return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Missing, + $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} is not a release of the supported " + + $"CheatEngine.SDK {supported}, the range of the package this Client build consumed " + + $"({ExpectedInformationalVersion})."), $"outside {supported}", false); + } + + private static ConsumedSdkIdentity ReadCurrent() + { + string? version = null; + string? sourceCommit = null; + string? contentHash = null; + string? supportedMajor = null; + foreach (AssemblyMetadataAttribute metadata in typeof(ConsumedSdkIdentity).Assembly + .GetCustomAttributes()) + { + switch (metadata.Key) + { + case VersionKey: + version = metadata.Value; + break; + case SourceCommitKey: + sourceCommit = metadata.Value; + break; + case ContentHashKey: + contentHash = metadata.Value; + break; + case SupportedMajorKey: + supportedMajor = metadata.Value; + break; + } + } + + string? loaded = typeof(RuntimeInfo).Assembly.GetCustomAttribute() + ?.InformationalVersion; + return new ConsumedSdkIdentity(version, sourceCommit, contentHash, supportedMajor, loaded); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + /// + /// The precedence-relevant part of a SemVer 2.0.0 version (https://semver.org/#spec-item-11): major, minor, patch + /// and prerelease identifiers. Build metadata is validated and ignored. + /// + private readonly struct SemanticVersion + { + private readonly string[] _prerelease; + + private SemanticVersion(int major, int minor, int patch, string[] prerelease) + { + Major = major; + Minor = minor; + Patch = patch; + _prerelease = prerelease; + } + + internal int Major + { + get; + } + + private int Minor + { + get; + } + + private int Patch + { + get; + } + + internal static bool TryParse(string value, out SemanticVersion version) + { + version = default; + int plus = value.IndexOf('+', StringComparison.Ordinal); + if (plus >= 0 && !AreIdentifiers(value[(plus + 1)..], false)) + { + return false; + } + + string precedence = plus < 0 ? value : value[..plus]; + int dash = precedence.IndexOf('-', StringComparison.Ordinal); + string[] core = (dash < 0 ? precedence : precedence[..dash]).Split('.'); + if (core.Length != 3 || !TryParseNumericIdentifier(core[0], out int major) || + !TryParseNumericIdentifier(core[1], out int minor) || !TryParseNumericIdentifier(core[2], out int patch)) + { + return false; + } + + string[] prerelease = []; + if (dash >= 0) + { + string identifiers = precedence[(dash + 1)..]; + if (!AreIdentifiers(identifiers, true)) + { + return false; + } + + prerelease = identifiers.Split('.'); + } + + version = new SemanticVersion(major, minor, patch, prerelease); + return true; + } + + /// Parses a SemVer numeric identifier: ASCII digits, no leading zero, within . + internal static bool TryParseNumericIdentifier(string? value, out int number) + { + number = 0; + return value is not null && IsNumeric(value) && (value.Length == 1 || value[0] != '0') && + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out number); + } + + /// Compares two versions by SemVer precedence: a release is above each of its prereleases. + internal static int Compare(SemanticVersion left, SemanticVersion right) + { + if (left.Major != right.Major) + { + return left.Major.CompareTo(right.Major); + } + + if (left.Minor != right.Minor) + { + return left.Minor.CompareTo(right.Minor); + } + + if (left.Patch != right.Patch) + { + return left.Patch.CompareTo(right.Patch); + } + + string[] leftPrerelease = left._prerelease ?? []; + string[] rightPrerelease = right._prerelease ?? []; + if (leftPrerelease.Length == 0 || rightPrerelease.Length == 0) + { + return rightPrerelease.Length.CompareTo(leftPrerelease.Length); + } + + for (int index = 0; index < Math.Min(leftPrerelease.Length, rightPrerelease.Length); index++) + { + int identifier = CompareIdentifiers(leftPrerelease[index], rightPrerelease[index]); + if (identifier != 0) + { + return identifier; + } + } + + return leftPrerelease.Length.CompareTo(rightPrerelease.Length); + } + + private static int CompareIdentifiers(string left, string right) + { + bool leftNumeric = IsNumeric(left); + bool rightNumeric = IsNumeric(right); + if (leftNumeric && rightNumeric) + { + // Numeric identifiers have no leading zero, so a longer one is larger. + return left.Length != right.Length + ? left.Length.CompareTo(right.Length) + : string.CompareOrdinal(left, right); + } + + if (leftNumeric != rightNumeric) + { + return leftNumeric ? -1 : 1; + } + + return string.CompareOrdinal(left, right); + } + + /// + /// Checks dot-separated SemVer identifiers: non-empty, ASCII alphanumerics and hyphens, and, for prerelease + /// identifiers, no leading zero on a numeric one. + /// + private static bool AreIdentifiers(string value, bool prerelease) + { + foreach (string identifier in value.Split('.')) + { + if (identifier.Length == 0 || !identifier.All(IsIdentifierCharacter)) + { + return false; + } + + if (prerelease && IsNumeric(identifier) && identifier.Length > 1 && identifier[0] == '0') + { + return false; + } + } + + return true; + } + + private static bool IsIdentifierCharacter(char character) + { + return char.IsAsciiLetterOrDigit(character) || character == '-'; + } + + private static bool IsNumeric(string value) + { + return value.Length > 0 && value.All(char.IsAsciiDigit); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs index 8e93cc8..3eef2b5 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs @@ -3,7 +3,8 @@ namespace CheatEngine.Client.Core.Infrastructure; /// Immutable activation policy supplied by the DI integration. internal sealed class CoreClientPolicy { - internal CoreClientPolicy(IEnumerable allowedTableRoots, bool enableUnsafeLuaExecution) + internal CoreClientPolicy(IEnumerable allowedTableRoots, bool enableUnsafeLuaExecution, + bool enableAutoAssemblerPatches = false) { ArgumentNullException.ThrowIfNull(allowedTableRoots); List roots = []; @@ -15,6 +16,7 @@ internal CoreClientPolicy(IEnumerable allowedTableRoots, bool enableUnsa AllowedTableRoots = roots.ToArray(); EnableUnsafeLuaExecution = enableUnsafeLuaExecution; + EnableAutoAssemblerPatches = enableAutoAssemblerPatches; } internal static CoreClientPolicy SafeDefaults @@ -32,6 +34,15 @@ internal bool EnableUnsafeLuaExecution get; } + /// + /// Gets whether the activation opted into Auto Assembler patches through the builder-only + /// EnableAutoAssemblerPatches(); configuration binding cannot set it. + /// + internal bool EnableAutoAssemblerPatches + { + get; + } + /// /// Validates a table path against the activation's explicit roots without following any observed link or junction. /// @@ -91,9 +102,9 @@ private static bool IsContainedBy(string normalizedRoot, string fullPath) string root = Path.TrimEndingDirectorySeparator(normalizedRoot); string relative = Path.GetRelativePath(root, fullPath); return !Path.IsPathFullyQualified(relative) && - !string.Equals(relative, "..", StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal); + !string.Equals(relative, "..", StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal); } private static bool TryVerifySafePath(string normalizedRoot, string fullPath, bool forLoad, out string reason) @@ -169,7 +180,7 @@ private static bool TryVerifyDirectoryChain(string directoryPath, out string rea } foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries)) + StringSplitOptions.RemoveEmptyEntries)) { current = Path.Combine(current, segment); if (!TryVerifyNotReparsePoint(new DirectoryInfo(current), out reason)) @@ -198,7 +209,7 @@ private static bool TryVerifyNotReparsePoint(FileSystemInfo item, out string rea } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException - or NotSupportedException) + or NotSupportedException) { reason = "The trusted table path cannot be verified without following a filesystem link."; return false; diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs index 29612fb..ea1cf8a 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs @@ -1,50 +1,146 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Values; using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Infrastructure; /// Maps implementation exceptions to the stable public client result vocabulary. +/// +/// +/// Classification uses exception types and the SDK's own failure categories only, never exception or Lua message +/// text: the text of a Cheat Engine or Lua error depends on the host language and version. +/// +/// +/// Every is classified by its , exhaustively +/// (), and a by its +/// (). A category this +/// Client version does not know is , and the SDK mapping contract +/// tests fail until it is mapped. +/// +/// internal static class CoreFailureFactory { + /// Creates the failure for a cancellation observed before any Cheat Engine work was dispatched. internal static CheatEngineFailure Cancelled(string operation) { - return new CheatEngineFailure( - CheatEngineFailureKind.Cancelled, - operation, - "The operation was cancelled before Cheat Engine work began."); + return CancellationMapping.BeforeNativeCall(operation); } + /// Maps an exception whose Cheat Engine side effect is unknown. internal static CheatEngineFailure FromException(string operation, Exception exception) + { + return FromException(operation, exception, CheatEngineHostEffect.Unknown); + } + + /// Maps an exception and records what is known about the Cheat Engine side effect. + /// + /// A failed ownership handoff (, + /// , ) + /// reports an effect Cheat Engine already accepted and that the SDK's single compensation may not have removed, so + /// an otherwise unknown effect is recorded as . A known + /// effect passed by the caller is kept. + /// + internal static CheatEngineFailure FromException(string operation, Exception exception, + CheatEngineHostEffect hostEffect) { ArgumentException.ThrowIfNullOrWhiteSpace(operation); ArgumentNullException.ThrowIfNull(exception); - return new CheatEngineFailure(GetKind(exception), operation, exception.Message, exception); + string message = string.IsNullOrWhiteSpace(exception.Message) + ? $"The operation failed with {exception.GetType().Name}." + : exception.Message; + if (hostEffect == CheatEngineHostEffect.Unknown && IsOwnershipHandoffFailure(exception)) + { + hostEffect = CheatEngineHostEffect.CleanupUnconfirmed; + } + + return new CheatEngineFailure(GetKind(exception), operation, message, exception, hostEffect); } - internal static CheatEngineFailure Lifecycle(string operation, string message, Exception? exception = null) + internal static CheatEngineFailure InvalidState(string operation, string message, Exception? exception = null) { return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, message, exception); } - private static CheatEngineFailureKind GetKind(Exception exception) + /// Returns the same failure with a more precise host effect. + internal static CheatEngineFailure WithHostEffect(CheatEngineFailure failure, CheatEngineHostEffect hostEffect) + { + return failure.HostEffect == hostEffect + ? failure + : new CheatEngineFailure(failure.Kind, failure.Operation, failure.Message, failure.Exception, hostEffect); + } + + /// Classifies an exception by its type and, for SDK exceptions, by the SDK's failure category. + /// + /// Internal so the consumer-contract tests can prove that every public exception type of the consumed + /// CheatEngine.SDK version maps to a known kind (Q48). and + /// derive from , so they precede that + /// arm. + /// + internal static CheatEngineFailureKind GetKind(Exception exception) { return exception switch { CheatEngineActivationExpiredException => CheatEngineFailureKind.ActivationExpired, - CheatEngineClientLifecycleException => CheatEngineFailureKind.InvalidState, - EngineCapabilityUnavailableException => CheatEngineFailureKind.CapabilityUnavailable, - EngineGlobalUnavailableException => CheatEngineFailureKind.CapabilityUnavailable, - EngineOperationFailedException => CheatEngineFailureKind.OperationRejected, - EngineLuaException => CheatEngineFailureKind.LuaError, + CheatEngineInvalidStateException => CheatEngineFailureKind.InvalidState, + EngineException engine => FromEngineFailureKind(engine.Kind), LuaException => CheatEngineFailureKind.LuaError, - EngineBindingException => CheatEngineFailureKind.BindingError, - EngineMarshallingException => CheatEngineFailureKind.InvalidHostResult, + // The session is busy (a Cheat Engine call is still running) or not in a state that accepts the call. + MemoryScanStateException => CheatEngineFailureKind.InvalidState, + MemoryScanException scan => FromMemoryScanFailureKind(scan.FailureKind), ObjectDisposedException => CheatEngineFailureKind.InvalidState, ArgumentException => CheatEngineFailureKind.OperationRejected, InvalidOperationException => CheatEngineFailureKind.OperationRejected, _ => CheatEngineFailureKind.Unknown }; } + + /// Maps an SDK engine failure category to the Client kind; exhaustive over the consumed SDK. + /// The category reported by . + /// + /// The Client kind; for a category this Client does not know. + /// + internal static CheatEngineFailureKind FromEngineFailureKind(EngineFailureKind kind) + { + return kind switch + { + EngineFailureKind.ExpectedOperationFailure => CheatEngineFailureKind.OperationRejected, + EngineFailureKind.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + EngineFailureKind.CapabilityUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + EngineFailureKind.ProtectedLuaFailure => CheatEngineFailureKind.LuaError, + EngineFailureKind.BindingFailure => CheatEngineFailureKind.BindingError, + EngineFailureKind.MarshallingFailure => CheatEngineFailureKind.InvalidHostResult, + EngineFailureKind.TargetIdentityUnavailable => CheatEngineFailureKind.TargetIdentityUnavailable, + EngineFailureKind.TargetIdentityMismatch => CheatEngineFailureKind.TargetChanged, + _ => CheatEngineFailureKind.Unknown + }; + } + + /// Maps an SDK memory-scan failure category to the Client kind; exhaustive over the consumed SDK. + /// The category reported by . + /// + /// The Client kind; for a category this Client does not know. + /// + internal static CheatEngineFailureKind FromMemoryScanFailureKind(MemoryScanFailureKind kind) + { + return kind switch + { + MemoryScanFailureKind.MissingCapability => CheatEngineFailureKind.CapabilityUnavailable, + MemoryScanFailureKind.LuaError => CheatEngineFailureKind.LuaError, + MemoryScanFailureKind.UnexpectedResult => CheatEngineFailureKind.InvalidHostResult, + MemoryScanFailureKind.RuntimeInvalidated => CheatEngineFailureKind.RuntimeChanged, + MemoryScanFailureKind.TargetIdentityUnavailable => CheatEngineFailureKind.TargetIdentityUnavailable, + MemoryScanFailureKind.TargetIdentityMismatch => CheatEngineFailureKind.TargetChanged, + _ => CheatEngineFailureKind.Unknown + }; + } + + /// Whether the exception reports a failed ownership handoff after Cheat Engine accepted the effect. + private static bool IsOwnershipHandoffFailure(Exception exception) + { + return exception is EngineResourceHandoffException or SymbolRegistrationHandoffException + or SymbolListRegistrationHandoffException; + } } diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs index c0d22f6..2c2c434 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs @@ -1,4 +1,3 @@ -using CheatEngine.Client.Results; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Context; @@ -13,15 +12,27 @@ internal sealed class CoreLifetime : IDisposable private int _disposed; private int _resourcesDrained; - private CoreLifetime(PluginContext context) - : this(new PluginContextAdapter(context)) + private CoreLifetime(PluginContext context, ICoreDiagnostics? diagnostics) + : this(new PluginContextAdapter(context), diagnostics) { } - internal CoreLifetime(ICoreLifetimeContext context) + /// Creates an activation lifetime over a context, with an optional diagnostics sink. + /// The captured plugin context. + /// + /// The Core diagnostics sink of this activation; every emit is guarded so a throwing sink never changes a result. + /// + internal CoreLifetime(ICoreLifetimeContext context, ICoreDiagnostics? diagnostics = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); TargetSelection = new TargetSelectionLifetime(ThrowIfInactive); + Diagnostics = GuardedCoreDiagnostics.Wrap(diagnostics); + } + + /// Gets the guarded diagnostics sink of this activation. + internal ICoreDiagnostics Diagnostics + { + get; } internal long Epoch => _context.Epoch; @@ -45,11 +56,11 @@ internal TargetSelectionLifetime TargetSelection /// main-thread cleanup scope may dispatch; no worker admission is reopened. /// internal bool CanDispatch => IsActivationCurrent && - (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread); + (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread); private bool IsInCleanupScopeOnMainThread => Volatile.Read(ref _cleanupScopeDepth) != 0 && - IsActivationCurrent && - _context.IsMainThread; + IsActivationCurrent && + _context.IsMainThread; /// Releases client-owned resources while the hosting plugin still owns SDK detach sequencing. public void Dispose() @@ -73,10 +84,10 @@ public void Dispose() /// internal IDisposable EnterCleanupScope() { - ThrowIfActivationCurrent("Client.EnterCleanupScope"); + ThrowIfActivationExpired("Client.EnterCleanupScope"); if (!_context.IsMainThread) { - throw new CheatEngineClientLifecycleException("Client.EnterCleanupScope", + throw ClientExceptions.InvalidState("Client.EnterCleanupScope", "Cheat Engine cleanup must run on the plugin main thread."); } @@ -95,7 +106,7 @@ internal void DrainOwnedResourcesForDisable() { if (!IsInCleanupScopeOnMainThread) { - throw new CheatEngineClientLifecycleException("Client.DrainResources", + throw ClientExceptions.InvalidState("Client.DrainResources", "Client-owned Cheat Engine resources can only be drained by the active main-thread cleanup scope."); } @@ -107,44 +118,63 @@ internal void DrainOwnedResourcesForDisable() DisposeOwnedResources(); } + /// + /// Releases target-selection resources, then activation resources, attempting every release and reporting every + /// failure: one failure is rethrown unchanged, several are aggregated in attempt order (audit Q43). + /// + /// + /// Client leases () never throw from : they stay + /// registered with the activation while their release is retryable or incomplete, and the activation drain + /// retries them once more and turns every incomplete outcome into one failure of this report. + /// private void DisposeOwnedResources() { - Exception? firstFailure = null; + List failures = []; try { - TargetSelection.Dispose(); + TargetSelection.DisposeCollecting(failures, ReportCleanupFailure); } catch (Exception exception) { - firstFailure = exception; + failures.Add(exception); } try { - _resources.Dispose(); + _resources.DisposeCollecting(failures, ReportCleanupFailure, reportOutcomes: true); } catch (Exception exception) { - firstFailure ??= exception; + failures.Add(exception); } - if (firstFailure is not null) - { - throw firstFailure; - } + CoreResourceRegistry.ThrowCleanupFailures(failures); + } + + /// Reports one failed release with the resource and exception type names only (A24-16). + private void ReportCleanupFailure(IDisposable resource, Exception exception) + { + Diagnostics.CoreResourceCleanupFailed(resource.GetType().Name, exception.GetType().FullName ?? + exception.GetType().Name); } internal static CoreLifetime Capture() + { + return Capture(null); + } + + /// Captures the enabled plugin context with the diagnostics sink of this activation. + internal static CoreLifetime Capture(ICoreDiagnostics? diagnostics) { PluginContext? context = PluginHost.Context; if (context is null || !context.IsCurrent) { - throw new CheatEngineClientLifecycleException( + throw ClientExceptions.InvalidState( "Client.Activate", "Cheat Engine has not enabled a plugin context for this client scope."); } - return new CoreLifetime(context); + return new CoreLifetime(context, diagnostics); } internal T Track(T resource) @@ -159,38 +189,53 @@ internal bool Untrack(IDisposable resource) return _resources.Untrack(resource); } + /// Rejects new work once the activation ends or starts stopping, the cleanup scope included. + /// + /// Creating a lease-owned resource is admitted only while the activation is active, never from the cleanup scope: + /// every operation that creates a lease (a symbol registration, a value-scan session, an allocation, an Auto + /// Assembler patch) calls this before it dispatches and again in its dispatched callback, before + /// CheatEngine.SDK creates anything that no lease could own. A Lua module registration calls it before it + /// dispatches and after the registration, and the activation tracks its lease before the dispatch, so the + /// cleanup scope drains a module that registered while the activation began stopping. Operations on an + /// existing resource use , so the cleanup scope can still release it. + /// + /// The public operation name. internal void ThrowIfInactive(string operation) { ArgumentException.ThrowIfNullOrWhiteSpace(operation); - ThrowIfActivationCurrent(operation); + ThrowIfActivationExpired(operation); if (Stopping.IsCancellationRequested) { - throw new CheatEngineClientLifecycleException(operation, + throw ClientExceptions.InvalidState(operation, "The Cheat Engine plugin lifecycle is stopping and no new client work is admitted."); } } - /// Rejects ordinary dispatch once admission closes, except for the current main-thread cleanup scope. - internal void ThrowIfDispatchAllowed(string operation) + /// + /// Throws when dispatch is refused: the activation ended, or it is stopping and the caller is not the current + /// main-thread cleanup scope. + /// + internal void ThrowIfDispatchRefused(string operation) { ArgumentException.ThrowIfNullOrWhiteSpace(operation); - ThrowIfActivationCurrent(operation); + ThrowIfActivationExpired(operation); if (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread) { return; } - throw new CheatEngineClientLifecycleException(operation, + throw ClientExceptions.InvalidState(operation, "The Cheat Engine plugin lifecycle is stopping and no new client work is admitted."); } - private void ThrowIfActivationCurrent(string operation) + /// Throws the activation-expired exception once the captured activation is no longer current. + private void ThrowIfActivationExpired(string operation) { if (!IsActivationCurrent) { - throw new CheatEngineActivationExpiredException(operation, + throw ClientExceptions.ActivationExpired(operation, "The Cheat Engine plugin lifecycle changed, so this client epoch is stale."); } } diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs index f0a05e3..64a5308 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs @@ -1,3 +1,5 @@ +using System.Runtime.ExceptionServices; + namespace CheatEngine.Client.Core.Infrastructure; /// Owns client-created resources for one active plugin epoch and releases them in reverse creation order. @@ -7,11 +9,27 @@ internal sealed class CoreResourceRegistry : IDisposable private bool _disposed; /// Releases every tracked resource even when an earlier cleanup fails. + /// Several resources failed to release; the inner exceptions keep attempt order. + /// A single release failure is rethrown as the same instance with its original stack trace. public void Dispose() { DisposeDetached(DetachAll()); } + /// Releases every tracked resource and appends each failure to in attempt order. + /// Receives every release failure, in attempt order. + /// Observes each failure with the resource that produced it. + /// + /// for the activation registry at deactivation: an + /// is released through , and an incomplete + /// outcome joins (audit Q43). + /// + internal void DisposeCollecting(List failures, Action? onFailure = null, + bool reportOutcomes = false) + { + DisposeDetached(DetachAll(), failures, onFailure, reportOutcomes); + } + internal T Track(T resource) where T : class, IDisposable { @@ -91,26 +109,81 @@ internal IDisposable[] DetachTargetSelection(long targetSelectionEpoch) } } - /// Disposes an insertion-ordered resource snapshot in reverse order, preserving the first fault. + /// + /// Disposes an insertion-ordered resource snapshot in reverse order, attempts every release, and reports every + /// failure (audit Q43). + /// + /// Several releases failed; the inner exceptions keep attempt order. + /// A single release failure is rethrown as the same instance with its original stack trace. internal static void DisposeDetached(IReadOnlyList resources) { - Exception? firstFailure = null; + List failures = []; + DisposeDetached(resources, failures); + ThrowCleanupFailures(failures); + } + + /// + /// Disposes a snapshot in reverse order, appends each failure to and reports it to + /// when supplied. + /// + /// + /// With , an is released through + /// instead of , + /// which never throws: the failure it returns for an incomplete release joins . + /// Without it (a target-selection change), such a resource is disposed like any other and keeps an incomplete + /// outcome for the activation's deactivation report. + /// + internal static void DisposeDetached(IReadOnlyList resources, List failures, + Action? onFailure = null, bool reportOutcomes = false) + { + ArgumentNullException.ThrowIfNull(failures); for (int index = resources.Count - 1; index >= 0; index--) { + IDisposable resource = resources[index]; + Exception? failure; try { - resources[index].Dispose(); + if (reportOutcomes && resource is IOutcomeReportingResource reporting) + { + failure = reporting.ReleaseForDeactivation(); + } + else + { + resource.Dispose(); + failure = null; + } } catch (Exception exception) { - firstFailure ??= exception; + failure = exception; } + + if (failure is not null) + { + failures.Add(failure); + onFailure?.Invoke(resource, failure); + } + } + } + + /// Throws nothing, the single failure unchanged, or one aggregate of every failure in attempt order. + /// + /// https://learn.microsoft.com/dotnet/standard/exceptions/best-practices-for-exceptions#capture-exceptions-to-rethrow-later + /// + internal static void ThrowCleanupFailures(List failures) + { + ArgumentNullException.ThrowIfNull(failures); + if (failures.Count == 0) + { + return; } - if (firstFailure is not null) + if (failures.Count == 1) { - throw firstFailure; + ExceptionDispatchInfo.Capture(failures[0]).Throw(); } + + throw new AggregateException("Client resource cleanup encountered several failures.", failures); } private T TrackCore(T resource, long? targetSelectionEpoch) diff --git a/libs/CheatEngine.Client.Core/Infrastructure/HostEffectMapping.cs b/libs/CheatEngine.Client.Core/Infrastructure/HostEffectMapping.cs new file mode 100644 index 0000000..59ad068 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/HostEffectMapping.cs @@ -0,0 +1,36 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Objects; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Maps the effect state that CheatEngine.SDK reports for an effectful operation to the Client vocabulary. +/// +/// +/// Every value of has one Client counterpart: NotStarted stays +/// , the documented negative result NotApplied stays +/// , a confirmed Applied effect is +/// (the primitive ran to completion), and Unknown stays +/// . +/// +/// +/// A value this Client version does not know is : an unrecognized +/// effect never reads as an established one. The mapping-totality tests fail when the consumed SDK adds a value. +/// +/// +internal static class HostEffectMapping +{ + /// Returns the Client host effect for an SDK effect state. + /// The effect state reported by CheatEngine.SDK. + /// The Client host effect; for an unrecognized value. + internal static CheatEngineHostEffect FromSdk(EngineEffectState state) + { + return state switch + { + EngineEffectState.NotStarted => CheatEngineHostEffect.NotStarted, + EngineEffectState.NotApplied => CheatEngineHostEffect.NotApplied, + EngineEffectState.Applied => CheatEngineHostEffect.Completed, + EngineEffectState.Unknown => CheatEngineHostEffect.Unknown, + _ => CheatEngineHostEffect.Unknown + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/HostResourceLease.cs b/libs/CheatEngine.Client.Core/Infrastructure/HostResourceLease.cs new file mode 100644 index 0000000..88d7f2d --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/HostResourceLease.cs @@ -0,0 +1,312 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// The one base of every Client lease: main-thread release, idempotence, registration, and reporting. +/// +/// +/// A derived lease implements only , which runs on Cheat Engine's main thread +/// through the activation dispatcher (SdkMainThreadDispatcher in production) and maps the SDK release +/// status with . This base serializes the attempts, records the outcome, and +/// never lets an exception escape or : an exception from the derived +/// release is with an +/// effect (a call may have begun, so it is never retried), and work that cannot be dispatched is +/// with . +/// +/// +/// tracks the lease in the activation registry and, for a target-bound lease, in the +/// target selection as well. A complete outcome unregisters it. A retryable outcome keeps it registered, so the +/// activation cleanup retries it before the plugin is disabled; an outcome that requires manual recovery keeps +/// it registered too, so the aggregated deactivation report (audit Q43) carries it. A target change disposes a +/// target-bound lease without throwing to the caller that selected the new target. +/// +/// +/// Every attempt is logged after the dispatched work returned, with its operation name, kind and effect only +/// (audit Q46). A release of a lease that already ended is not an attempt: it returns the outcome that ended the +/// lease (), with no Cheat Engine call and no log entry. +/// +/// +internal abstract class HostResourceLease : ICheatEngineLease, IOutcomeReportingResource +{ + private static readonly LeaseReleaseOutcome UnavailableOutcome = + new(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted); + + private static readonly LeaseReleaseOutcome UnexpectedFaultOutcome = + new(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown); + + private readonly ICoreDiagnostics _diagnostics; + private readonly ICheatEngineDispatcher _dispatcher; + private readonly Lock _gate = new(); + private LeaseReleaseOutcome? _lastOutcome; + private CoreLifetime? _lifetime; + private int _released; + private bool _targetBound; + + /// Creates a lease that releases through . + /// The stable operation name of the release, for example Allocations.Release. + /// The activation dispatcher that runs the release on Cheat Engine's main thread. + /// The activation diagnostics; nothing is logged when omitted. + protected HostResourceLease(string operation, ICheatEngineDispatcher dispatcher, ICoreDiagnostics? diagnostics) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + Operation = operation; + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _diagnostics = GuardedCoreDiagnostics.Wrap(diagnostics); + } + + /// Gets the stable operation name of the release, the only text its logs and reports carry. + internal string Operation + { + get; + } + + public bool IsReleased => Volatile.Read(ref _released) != 0; + + public bool RequiresManualRecovery => LastReleaseOutcome is { RequiresManualRecovery: true } || + OwnerRequiresManualRecovery; + + /// + /// Gets whether the owner reports that a release attempt consumed its only means of release although the recorded + /// outcome stays retryable: an Auto Assembler patch whose release returned a status this Client version does not + /// recognize. Only a release attempt sets it; it never anticipates one. + /// + protected virtual bool OwnerRequiresManualRecovery => false; + + public LeaseReleaseOutcome? LastReleaseOutcome + { + get + { + lock (_gate) + { + return _lastOutcome; + } + } + } + + public LeaseReleaseOutcome Release() + { + if (TryGetEndingOutcome(out LeaseReleaseOutcome ending)) + { + // An ended lease returns the outcome that ended it: no Cheat Engine call and nothing new to log. + return ending; + } + + LeaseReleaseOutcome outcome = Attempt(out bool recorded); + if (recorded) + { + _diagnostics.LeaseReleased(Operation, outcome.Kind, outcome.HostEffect); + } + + return outcome; + } + + /// Releases the lease like and discards the outcome; it never throws. + public void Dispose() + { + try + { + _ = Release(); + } + catch (Exception) + { + // Deliberately ignored: Dispose never throws (the outcome stays in LastReleaseOutcome). + } + } + + Exception? IOutcomeReportingResource.ReleaseForDeactivation() + { + try + { + // An ended lease reports the outcome that ended it (LastReleaseOutcome) without another attempt. + LeaseReleaseOutcome outcome = Release(); + return outcome.IsComplete ? null : CreateReport(outcome); + } + catch (Exception exception) + { + return exception; + } + } + + /// Registers the lease with the activation that owns it, and with its target selection when bound to one. + /// The owning activation. + /// + /// The target-selection epoch the resource belongs to, or for a resource that is not bound + /// to the selected target. + /// + /// + /// The activation is no longer active, or the target selection already changed; the lease is not registered. + /// + internal void Register(CoreLifetime lifetime, long? targetSelectionEpoch = null) + { + ArgumentNullException.ThrowIfNull(lifetime); + lock (_gate) + { + if (_lifetime is not null) + { + throw new InvalidOperationException("A Client lease is registered with its activation only once."); + } + + _lifetime = lifetime; + _targetBound = targetSelectionEpoch.HasValue; + } + + lifetime.Track(this); + if (targetSelectionEpoch is not { } epoch) + { + return; + } + + try + { + _ = lifetime.TargetSelection.Track(this, epoch); + } + catch (Exception) + { + _ = lifetime.Untrack(this); + throw; + } + } + + /// + /// Releases the resource. It runs on Cheat Engine's main thread, one attempt at a time, and never after the lease + /// ended. + /// + /// The outcome, usually mapped from the SDK release status with . + /// An exception is recorded as an unconfirmed cleanup with an unknown effect and never retried. + protected abstract LeaseReleaseOutcome ReleaseOnMainThread(); + + /// Maps an incomplete outcome to the failure kind that the deactivation report carries. + private static CheatEngineFailureKind GetReportKind(LeaseReleaseKind kind) + { + return kind switch + { + LeaseReleaseKind.RefusedTargetNotAttached => CheatEngineFailureKind.TargetNotAttached, + LeaseReleaseKind.RefusedTargetChanged => CheatEngineFailureKind.TargetChanged, + LeaseReleaseKind.RefusedTargetIdentityUnavailable => CheatEngineFailureKind.TargetIdentityUnavailable, + LeaseReleaseKind.RefusedRuntimeChanged => CheatEngineFailureKind.RuntimeChanged, + LeaseReleaseKind.CleanupUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + LeaseReleaseKind.CleanupUnconfirmed or LeaseReleaseKind.PartiallyReleased => + CheatEngineFailureKind.IndeterminateHostResult, + _ => CheatEngineFailureKind.Unknown + }; + } + + /// Gets the outcome that ended the lease, when an earlier attempt ended it. + private bool TryGetEndingOutcome(out LeaseReleaseOutcome ending) + { + lock (_gate) + { + return TryGetEndingOutcomeUnderGate(out ending); + } + } + + /// Gets the outcome that ended the lease; the caller holds the gate. + private bool TryGetEndingOutcomeUnderGate(out LeaseReleaseOutcome ending) + { + // Record sets the outcome before it ends the lease, so an ended lease always has one. + if (IsReleased && _lastOutcome is { } last) + { + ending = last; + return true; + } + + ending = default; + return false; + } + + /// Makes one attempt; is false when another attempt ended the lease first. + private LeaseReleaseOutcome Attempt(out bool recorded) + { + bool ran = false; + bool attempted = false; + LeaseReleaseOutcome dispatched = default; + try + { + // A release is never cancelled: the deactivation cleanup releases leases while the activation stops, so the + // dispatch deliberately opts out of the activation's Stopping token. + _ = _dispatcher.TryInvoke(() => + { + dispatched = ReleaseUnderGate(out attempted); + ran = true; + return true; + }, out bool _, out CheatEngineFailure _, CancellationToken.None); + } + catch (Exception) + { + // Dispatch was refused (activation stopping or ended, or no main-thread admission): nothing ran unless the + // callback already recorded its outcome below. + } + + if (ran) + { + recorded = attempted; + return dispatched; + } + + return RecordUnavailable(out recorded); + } + + private LeaseReleaseOutcome ReleaseUnderGate(out bool recorded) + { + lock (_gate) + { + if (TryGetEndingOutcomeUnderGate(out LeaseReleaseOutcome ending)) + { + // Another attempt ended the lease while this one waited for the main thread. + recorded = false; + return ending; + } + + recorded = true; + LeaseReleaseOutcome outcome; + try + { + outcome = ReleaseOnMainThread(); + } + catch (Exception) + { + outcome = UnexpectedFaultOutcome; + } + + return Record(outcome); + } + } + + private LeaseReleaseOutcome RecordUnavailable(out bool recorded) + { + lock (_gate) + { + recorded = !TryGetEndingOutcomeUnderGate(out LeaseReleaseOutcome ending); + return recorded ? Record(UnavailableOutcome) : ending; + } + } + + /// Records an attempt's outcome; the caller holds the gate. + private LeaseReleaseOutcome Record(LeaseReleaseOutcome outcome) + { + _lastOutcome = outcome; + if (!outcome.IsRetryable) + { + Volatile.Write(ref _released, 1); + } + + if (outcome.IsComplete && _lifetime is { } lifetime) + { + _ = lifetime.Untrack(this); + if (_targetBound) + { + _ = lifetime.TargetSelection.Untrack(this); + } + } + + return outcome; + } + + private Exception CreateReport(LeaseReleaseOutcome outcome) + { + return new CheatEngineFailure(GetReportKind(outcome.Kind), Operation, + $"The lease release ended with {outcome.Kind} (host effect: {outcome.HostEffect}); the resource may " + + "remain in Cheat Engine or in the target.", null, CheatEngineHostEffect.CleanupUnconfirmed).ToException(); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ICoreDiagnostics.cs b/libs/CheatEngine.Client.Core/Infrastructure/ICoreDiagnostics.cs new file mode 100644 index 0000000..35a354a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ICoreDiagnostics.cs @@ -0,0 +1,334 @@ +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// Logger-free sink for the bounded, redacted Core diagnostic events (audit ch.24, A11-03, A24-12 to A24-17). +/// +/// +/// +/// Core has no logging dependency: the dependency-injection package implements this sink over +/// Microsoft.Extensions.Logging with source-generated events and one category per domain. Every parameter is +/// a count, an epoch, a width, a stable operation name or a closed reason name: an event never carries an address, +/// a value, a symbol name, a path, a script body, an exception message or a failure object. +/// +/// +/// Core emits after the dispatched Cheat Engine work returned, never inside a dispatched callback, and every emit +/// goes through , so a throwing sink never changes an operation result. +/// +/// +internal interface ICoreDiagnostics +{ + /// A runtime snapshot was captured (EventId 1000). + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch); + + /// A capability gate refused an operation (EventId 1001). + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState); + + /// The target-selection epoch advanced (EventId 1100). + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason); + + /// A pointer-typed operation was refused on a configured/process width mismatch (EventId 1200). + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes); + + /// A primitive batch completed, fully or partially (EventId 1201). + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState); + + /// A trusted table load reached Cheat Engine and advanced the table generation (EventId 1300). + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration); + + /// A record identifier captured before the last trusted load was refused (EventId 1301). + public void StaleRecordIdentifierRefused(string operation, long tableGeneration); + + /// An activation request was refused, pending or indeterminate (EventId 1302). + public void RecordActivationNotApplied(string operation, bool requestedState, string status); + + /// A symbol registration was rejected by the collision preflight (EventId 1400). + public void SymbolRegistrationRejected(string operation, string reason); + + /// A pattern scan ended with metrics (EventId 1500). + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, bool truncated, + long hostScanMilliseconds, long copyMilliseconds); + + /// A Lua operation ended (EventId 1600); the script length is non-zero for unsafe Lua only. + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength); + + /// A Client-owned resource failed to release (EventId 1700). + public void CoreResourceCleanupFailed(string componentType, string exceptionType); + + /// A Client lease release attempt ended (EventId 1701); only the kind, operation and effect are logged. + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect); + + /// + /// Cheat Engine applied an Auto Assembler patch while the selected target changed (EventId 1800, a warning); only + /// the operation and the selection epoch the patch is bound to are logged. + /// + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch); +} + +/// The sink used when no diagnostics are configured. +internal sealed class NullCoreDiagnostics : ICoreDiagnostics +{ + private NullCoreDiagnostics() + { + } + + internal static NullCoreDiagnostics Instance + { + get; + } = new(); + + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch) + { + } + + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState) + { + } + + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason) + { + } + + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes) + { + } + + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState) + { + } + + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration) + { + } + + public void StaleRecordIdentifierRefused(string operation, long tableGeneration) + { + } + + public void RecordActivationNotApplied(string operation, bool requestedState, string status) + { + } + + public void SymbolRegistrationRejected(string operation, string reason) + { + } + + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, bool truncated, + long hostScanMilliseconds, long copyMilliseconds) + { + } + + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength) + { + } + + public void CoreResourceCleanupFailed(string componentType, string exceptionType) + { + } + + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + } + + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch) + { + } +} + +/// Contains every sink failure so diagnostics can never change a Client operation or cleanup result (A24-22). +internal sealed class GuardedCoreDiagnostics(ICoreDiagnostics inner) : ICoreDiagnostics +{ + private readonly ICoreDiagnostics _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + /// Returns the null sink for , or a guarded sink that swallows every sink failure. + internal static ICoreDiagnostics Wrap(ICoreDiagnostics? diagnostics) + { + return diagnostics switch + { + null => NullCoreDiagnostics.Instance, + NullCoreDiagnostics or GuardedCoreDiagnostics => diagnostics, + _ => new GuardedCoreDiagnostics(diagnostics) + }; + } + + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch) + { + try + { + _inner.RuntimeSnapshotCaptured(activationEpoch, targetArchitecture, processPointerBytes, + configuredPointerBytes, pointerSizeMismatch); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState) + { + try + { + _inner.CapabilityRefused(capability, operation, gate, gateState); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason) + { + try + { + _inner.TargetSelectionAdvanced(activationEpoch, selectionEpoch, operation, reason); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes) + { + try + { + _inner.PointerWidthMismatchRefused(operation, processPointerBytes, configuredPointerBytes); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState) + { + try + { + _inner.MemoryBatchCompleted(operation, requested, completed, effectState); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration) + { + try + { + _inner.TableGenerationAdvanced(activationEpoch, tableGeneration); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void StaleRecordIdentifierRefused(string operation, long tableGeneration) + { + try + { + _inner.StaleRecordIdentifierRefused(operation, tableGeneration); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void RecordActivationNotApplied(string operation, bool requestedState, string status) + { + try + { + _inner.RecordActivationNotApplied(operation, requestedState, status); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void SymbolRegistrationRejected(string operation, string reason) + { + try + { + _inner.SymbolRegistrationRejected(operation, reason); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, bool truncated, + long hostScanMilliseconds, long copyMilliseconds) + { + try + { + _inner.PatternScanCompleted(scope, hostResultCount, materializedCount, truncated, hostScanMilliseconds, + copyMilliseconds); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength) + { + try + { + _inner.LuaOperationCompleted(operation, outcome, elapsedMilliseconds, scriptLength); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } + + public void CoreResourceCleanupFailed(string componentType, string exceptionType) + { + try + { + _inner.CoreResourceCleanupFailed(componentType, exceptionType); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the cleanup result. + } + } + + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + try + { + _inner.LeaseReleased(operation, kind, hostEffect); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the release outcome. + } + } + + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch) + { + try + { + _inner.AutoAssemblerPatchAppliedAfterTargetChange(operation, selectionEpoch); + } + catch (Exception) + { + // Deliberately ignored: diagnostics must never change the operation result. + } + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs b/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs index 56ecb06..c96484b 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs @@ -1,4 +1,4 @@ -namespace CheatEngine.Client.Core.Infrastructure; +namespace CheatEngine.Client.Core.Infrastructure; internal interface ICoreLifetimeContext { diff --git a/libs/CheatEngine.Client.Core/Infrastructure/IOutcomeReportingResource.cs b/libs/CheatEngine.Client.Core/Infrastructure/IOutcomeReportingResource.cs new file mode 100644 index 0000000..403d463 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/IOutcomeReportingResource.cs @@ -0,0 +1,22 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// A Client-owned resource whose never throws and whose release reports an outcome +/// instead: the activation drain asks it for the failure to report. +/// +internal interface IOutcomeReportingResource : IDisposable +{ + /// + /// Releases the resource for the deactivation of its activation, retrying a retryable release once more, and + /// returns the failure the deactivation report must carry. + /// + /// + /// when the release is complete; otherwise an exception that describes the incomplete + /// outcome with safe fields only (kind, operation and effect). + /// + /// + /// Called by the activation drain, on Cheat Engine's main thread inside the cleanup scope when the plugin is + /// disabled. It never throws. + /// + public Exception? ReleaseForDeactivation(); +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/LeaseRegistration.cs b/libs/CheatEngine.Client.Core/Infrastructure/LeaseRegistration.cs new file mode 100644 index 0000000..b0f7522 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/LeaseRegistration.cs @@ -0,0 +1,50 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Reports a new lease that could not be registered once the resource it would own was released at once. +/// +/// A domain checks that the activation admits new work before Cheat Engine creates a resource, so a registration +/// refused afterwards means that the activation stopped or ended during the call, or that the target selection moved +/// on. Either way the resource is released in the same main-thread callback and no lease is published; what the +/// release left is in the message, so that an allocation that may remain can be recovered by other means. +/// +internal static class LeaseRegistration +{ + /// Creates the failure of a refused registration, or throws the activation's lifecycle exception. + /// The activation that refused the registration. + /// The public Client operation name. + /// The exception of the refused registration. + /// The outcome of the release made because no lease was published. + /// What was released, for the message, for example the address and size of an allocation. + /// + /// While the activation still admits work, a failure (the + /// target selection moved on): when the release was confirmed, + /// otherwise . A thrown exception carries the same effect. + /// + /// The activation ended; the message says what remains. + /// The activation is stopping; the message says what remains. + internal static CheatEngineFailure Refused(CoreLifetime lifetime, string operation, Exception registration, + LeaseReleaseOutcome released, string resource) + { + ArgumentNullException.ThrowIfNull(lifetime); + string message = released.IsComplete + ? $"No lease could be registered for {resource}, which was released at once." + : $"No lease could be registered for {resource}, and releasing it ended with {released.Kind}: it may " + + "remain in Cheat Engine or in the target."; + CheatEngineHostEffect effect = + released.IsComplete ? CheatEngineHostEffect.Completed : CheatEngineHostEffect.CleanupUnconfirmed; + if (!lifetime.IsActivationCurrent) + { + throw ClientExceptions.ActivationExpired(operation, message, registration, effect); + } + + if (!lifetime.IsCurrent) + { + throw ClientExceptions.InvalidState(operation, message, registration, effect); + } + + return new CheatEngineFailure(CheatEngineFailureKind.TargetChanged, operation, + message + " The target selection changed before the lease was registered.", registration, effect); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/LuaAdmission.cs b/libs/CheatEngine.Client.Core/Infrastructure/LuaAdmission.cs new file mode 100644 index 0000000..8e06432 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/LuaAdmission.cs @@ -0,0 +1,125 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// The single point where Core asks CheatEngine.SDK to admit a Lua operation, and classifies a refusal from the SDK's +/// factual instead of an exception message. +/// +/// +/// +/// Only is a success. Every refusal is reported with +/// , because the SDK decides admission before the host's state +/// provider or any Lua call runs: +/// +/// +/// +/// +/// Detached and TransitionInProgress: +/// (the plugin is disabled, or its lifecycle +/// transition is draining); +/// +/// +/// +/// +/// ExternalStateReset: (Cheat Engine replaced +/// its Lua state outside the plugin's control); +/// +/// +/// +/// +/// ThreadNotAdmitted and NoStateForThread: +/// (a Client bug: Core only runs Lua work on Cheat +/// Engine's main thread, ADR-07); +/// +/// +/// +/// +/// Unknown and any value this Client version does not know: +/// , never a success, like every other +/// SDK outcome the Client does not recognize. +/// +/// +/// +/// +/// A refusal is never : nothing reached Cheat Engine. +/// +/// +/// It classifies only the admissions Core asks for itself, such as unsafe Lua execution. A CheatEngine.SDK +/// command that acquires its own admission, such as an AddressListMutations command or a +/// CheatTableFiles call, raises a plain when it is refused: +/// reports it as with an +/// unknown effect, unless the activation ended () or the SDK +/// detected an external Lua state reset (). +/// +/// +internal static class LuaAdmission +{ + private const string DetachedMessage = + "CheatEngine.SDK refused the Lua operation (Detached): the plugin is not enabled, so the Client activation has " + + "ended."; + + private const string TransitionMessage = + "CheatEngine.SDK refused the Lua operation (TransitionInProgress): a plugin lifecycle transition is draining, so " + + "the Client activation is ending."; + + private const string ExternalResetMessage = + "CheatEngine.SDK refused the Lua operation (ExternalStateReset): Cheat Engine replaced its Lua state outside the " + + "plugin's control. Disable and re-enable the plugin to recover."; + + private const string ThreadNotAdmittedMessage = + "Client bug: called off the main thread. CheatEngine.SDK refused the Lua operation (ThreadNotAdmitted)."; + + private const string NoStateForThreadMessage = + "Client bug: called off the main thread. Cheat Engine provided no Lua state for the calling thread " + + "(NoStateForThread)."; + + private const string UnknownMessage = + "CheatEngine.SDK reported no recognized Lua admission outcome; the operation was not started."; + + /// Asks CheatEngine.SDK to admit a Lua operation on the calling thread. + /// The public Client operation name used in the failure. + /// The admitted operation on success; dispose it before returning to Cheat Engine. + /// The classified refusal when the operation was not admitted. + /// only when the SDK reported . + internal static bool TryAcquire(string operation, out LuaRuntimeOperation admitted, out CheatEngineFailure failure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + + // The SDK hands out an admitted operation only with Admitted, the only status classified as a success; for every + // other status the operation is the default value, which owns no admission. + return TryClassify(LuaRuntime.TryAcquireOperationWithOutcome(out admitted), operation, out failure); + } + + /// Classifies an SDK admission outcome; the seam that the mapping-totality tests exercise. + /// The factual outcome reported by CheatEngine.SDK. + /// The public Client operation name used in the failure. + /// The classified refusal; the default value on success. + /// only for . + internal static bool TryClassify(LuaAdmissionStatus status, string operation, out CheatEngineFailure failure) + { + failure = status switch + { + LuaAdmissionStatus.Admitted => default, + LuaAdmissionStatus.Detached => Refused(CheatEngineFailureKind.ActivationExpired, operation, DetachedMessage), + LuaAdmissionStatus.TransitionInProgress => Refused(CheatEngineFailureKind.ActivationExpired, operation, + TransitionMessage), + LuaAdmissionStatus.ExternalStateReset => Refused(CheatEngineFailureKind.RuntimeChanged, operation, + ExternalResetMessage), + LuaAdmissionStatus.ThreadNotAdmitted => Refused(CheatEngineFailureKind.InvalidState, operation, + ThreadNotAdmittedMessage), + LuaAdmissionStatus.NoStateForThread => Refused(CheatEngineFailureKind.InvalidState, operation, + NoStateForThreadMessage), + LuaAdmissionStatus.Unknown => Refused(CheatEngineFailureKind.IndeterminateHostResult, operation, + UnknownMessage), + _ => Refused(CheatEngineFailureKind.IndeterminateHostResult, operation, UnknownMessage) + }; + return status == LuaAdmissionStatus.Admitted; + } + + private static CheatEngineFailure Refused(CheatEngineFailureKind kind, string operation, string message) + { + return new CheatEngineFailure(kind, operation, message, null, CheatEngineHostEffect.NotStarted); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/OwnershipHandoff.cs b/libs/CheatEngine.Client.Core/Infrastructure/OwnershipHandoff.cs new file mode 100644 index 0000000..e98bee6 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/OwnershipHandoff.cs @@ -0,0 +1,121 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Transfers release authority from an acquired owner to the Client object that publishes it. +/// +/// +/// Between the acquisition of an owner (for example the SDK Owned<StringList> returned by +/// AobScanner.TryScanOutcome) and the publication of the Client wrapper that will release it, exactly one +/// party must remain responsible for the release (audit F13). If publication fails for any reason, including an +/// allocation failure, this helper releases the owner exactly once and never retries the release. +/// +/// +/// On success the published object is the sole release authority; the helper never touches the owner again. +/// +/// +internal static class OwnershipHandoff +{ + /// Publishes through or releases it exactly once. + /// The acquired owner type. + /// The published object that becomes the single release authority. + /// The acquired owner. The caller must not use it after this call. + /// Creates the object that takes over release authority. + /// + /// Releases the owner once and reports the outcome (for an SDK owner, ReleaseWithOutcome mapped through + /// ). Called only when publication fails. + /// + /// The published object. + /// + /// or is . + /// + /// + /// Publication failed and the release of was not confirmed. The exception carries the + /// release kind ( when the release threw); its first inner exception is the + /// publication failure and a second inner exception is the release failure when the release threw. + /// + /// + /// When publication fails and the release is confirmed (), the original + /// publication exception is rethrown unchanged (same instance, original stack trace). + /// + internal static TResult Adopt(TOwner owner, Func publish, + Func release) + where TOwner : class + { + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(release); + + try + { + ArgumentNullException.ThrowIfNull(publish); + return publish(owner); + } + catch (Exception publishFailure) + { + LeaseReleaseOutcome outcome; + try + { + outcome = release(owner); + } + catch (Exception releaseFailure) + { + throw new OwnershipHandoffException(LeaseReleaseKind.Unknown, publishFailure, releaseFailure); + } + + if (outcome.Kind != LeaseReleaseKind.Released) + { + throw new OwnershipHandoffException(outcome.Kind, publishFailure, null); + } + + throw; + } + } + + /// Reports a failed handoff whose release was not confirmed, for the operation that called the port. + /// The public Client operation name. + /// The failed handoff. + /// What the owner held, for the message (for example AOB result list). + /// The owning activation lifetime, when the caller has one. + /// + /// The publication fault classified by with a completed effect (Cheat Engine + /// returned the owner), then marked with the release kind + /// (). + /// + /// The activation ended while the SDK call ran. + /// + /// Every route that publishes an SDK owner through reports its failed + /// handoff with this one mapping: the AOB result list and the Auto Assembler patch. A handoff whose release was + /// confirmed rethrows the publication fault instead, which the route classifies like any SDK fault. + /// + internal static CheatEngineFailure ToFailure(string operation, OwnershipHandoffException handoff, string subject, + CoreLifetime? lifetime) + { + ArgumentNullException.ThrowIfNull(handoff); + CheatEngineFailure publishFailure = SdkBoundary.Translate(operation, handoff.PublishFailure, + CheatEngineHostEffect.Completed, lifetime); + return WithUnconfirmedRelease(publishFailure, subject, handoff.ReleaseKind, handoff.ReleaseFailure); + } + + /// Adds a release that was not confirmed to the failure that caused it. + /// The failure that caused the release. + /// What the released owner held, for the message. + /// The release kind, never . + /// The exception the release threw, if it threw. + /// + /// The failure with its kind and operation, the release appended to its message, both exceptions, and + /// . + /// + internal static CheatEngineFailure WithUnconfirmedRelease(CheatEngineFailure primary, string subject, + LeaseReleaseKind released, Exception? releaseFault) + { + Exception? exception = (primary.Exception, releaseFault) switch + { + ({ } cause, { } fault) => new AggregateException(cause, fault), + ({ } cause, null) => cause, + _ => releaseFault + }; + return new CheatEngineFailure(primary.Kind, primary.Operation, + $"{primary.Message} The {subject} release was not confirmed ({released}).", exception, + CheatEngineHostEffect.CleanupUnconfirmed); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/OwnershipHandoffException.cs b/libs/CheatEngine.Client.Core/Infrastructure/OwnershipHandoffException.cs new file mode 100644 index 0000000..fc8bf73 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/OwnershipHandoffException.cs @@ -0,0 +1,55 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// Reports that could not publish an acquired owner and that +/// the owner's release was not confirmed. +/// +/// +/// +/// The release kind is typed (), so a caller reports +/// without reading the message. The first inner exception +/// is the publication failure; a second inner exception is the release failure when the release threw. +/// +/// +/// It never leaves Core: the domain that called the port maps it to its own classified failure. +/// +/// +internal sealed class OwnershipHandoffException : AggregateException +{ + /// Creates the report of a failed handoff whose release was not confirmed. + /// + /// The release outcome, never ; + /// when the release threw. + /// + /// The exception that prevented publication. + /// The exception the release threw, if it threw. + internal OwnershipHandoffException(LeaseReleaseKind releaseKind, Exception publishFailure, + Exception? releaseFailure) + : base($"The acquired resource could not be published, and its release was not confirmed ({releaseKind}).", + releaseFailure is null ? [publishFailure] : [publishFailure, releaseFailure]) + { + ReleaseKind = releaseKind; + PublishFailure = publishFailure; + ReleaseFailure = releaseFailure; + } + + /// Gets how the release of the acquired owner ended; never . + internal LeaseReleaseKind ReleaseKind + { + get; + } + + /// Gets the exception that prevented publication. + internal Exception PublishFailure + { + get; + } + + /// Gets the exception the release threw, or when it returned an outcome. + internal Exception? ReleaseFailure + { + get; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/SdkBoundary.cs b/libs/CheatEngine.Client.Core/Infrastructure/SdkBoundary.cs new file mode 100644 index 0000000..700569a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/SdkBoundary.cs @@ -0,0 +1,169 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Translates exceptions raised by Client-internal CheatEngine.SDK calls into classified failures. +/// +/// +/// Audit F15 / A11-31: no SDK exception type (LuaException, EngineLuaException, +/// EngineGlobalUnavailableException, EngineMarshallingException, a detached-runtime +/// , ...) may cross a Client Try* method. Core wraps every +/// Client-internal SDK call, whether a domain client makes it or its Sdk*Port adapter does: +/// TargetMemory, EngineInspection, SymbolRegistry, AobScanner, +/// MemoryScanSessions, TargetMemoryAllocator, AutoAssemblerPatcher, the instruction +/// assembler, disassembler and navigator, the runtime, host and process observations, process selection, +/// Address List access and mutations, CheatTableFiles, and protected Lua execution. It maps a fault +/// through , by exception type and SDK failure category, never by message text. +/// +/// +/// Two kinds of exception are never translated. Client lifecycle exceptions +/// ( and its subclasses, notably +/// ) keep their meaning and propagate. Exceptions from +/// consumer-supplied code (codecs, ILuaOperation, dispatcher callbacks) are not wrapped at all: the +/// dispatcher rethrows them unchanged by contract. A Lua module's ILuaModule.Register is the exception +/// to that rule: a generated module surfaces the CheatEngine.SDK faults of its registration from it (F15), so +/// LuaClient reports the failure a or a +/// carries and classifies any other exception with +/// . +/// +/// +/// When the activation is no longer current, an SDK fault is reported as +/// rather than as an ordinary failure, so an expired activation +/// is never reclassified as a rejection, a cancellation, or an unavailable capability (A10-21). +/// +/// +/// When CheatEngine.SDK has detected that Cheat Engine replaced its Lua state outside the plugin's control +/// (, sticky until the next attach), every admission path +/// of the SDK refuses with a plain . Such a fault is reported as +/// , never as a rejection. SDK exceptions that carry their +/// own category keep it. +/// +/// +internal static class SdkBoundary +{ + /// + /// Gets the SDK's sticky external Lua state reset fact (), the + /// same fact this boundary classifies faults with. Hosting reads it through the dependency-injection cleanup + /// bridge after the releases of a deactivation and logs event 8 when it is set (A8). + /// + internal static bool ExternalStateResetDetected => LuaRuntime.ExternalStateResetDetected; + + /// Gets whether is an SDK or host fault that a Try* method must translate. + internal static bool IsSdkFault(Exception exception) + { + return exception is not CheatEngineClientException; + } + + /// Maps an SDK fault to a failure, or throws the activation-expired exception when the activation ended. + /// The public Client operation name, for example Memory.ReadBytes. + /// The SDK fault. + /// What is known about the Cheat Engine side effect when the fault was observed. + /// The owning activation lifetime, when the caller has one. + /// The classified failure. + /// The activation expired while the SDK call ran. + internal static CheatEngineFailure Translate(string operation, Exception exception, + CheatEngineHostEffect hostEffect, CoreLifetime? lifetime) + { + ArgumentNullException.ThrowIfNull(exception); + ThrowIfActivationEnded(operation, exception, lifetime); + return Classify(operation, exception, hostEffect, LuaRuntime.ExternalStateResetDetected); + } + + /// Classifies an SDK fault with the SDK's current external Lua state reset fact. + /// The public Client operation name. + /// The SDK fault. + /// What is known about the Cheat Engine side effect when the fault was observed. + /// The classified failure; see . + /// + /// For callers that already handled the activation lifetime themselves, such as the dispatcher, so every SDK + /// fault observed after an external reset is reported the same way. + /// + internal static CheatEngineFailure Classify(string operation, Exception exception, CheatEngineHostEffect hostEffect) + { + return Classify(operation, exception, hostEffect, LuaRuntime.ExternalStateResetDetected); + } + + /// Classifies an SDK fault given the SDK's external Lua state reset fact. + /// The public Client operation name. + /// The SDK fault. + /// What is known about the Cheat Engine side effect when the fault was observed. + /// + /// The value of when the fault was observed; a parameter so + /// the rule is testable without a host. + /// + /// + /// for an otherwise unclassified + /// observed after an external reset; otherwise the + /// classification. + /// + internal static CheatEngineFailure Classify(string operation, Exception exception, CheatEngineHostEffect hostEffect, + bool externalStateResetDetected) + { + CheatEngineFailure failure = CoreFailureFactory.FromException(operation, exception, hostEffect); + return externalStateResetDetected && exception is InvalidOperationException && + failure.Kind == CheatEngineFailureKind.OperationRejected + ? new CheatEngineFailure(CheatEngineFailureKind.RuntimeChanged, failure.Operation, failure.Message, + exception, failure.HostEffect) + : failure; + } + + /// Dispatches Client-internal SDK work and returns an SDK fault as a failure instead of rethrowing it. + /// The dispatcher that runs on Cheat Engine's main thread. + /// The public Client operation name. + /// Client-internal SDK work only. Never pass consumer-supplied code: it must keep the dispatcher's + /// unchanged-rethrow rule. + /// What is known about the Cheat Engine side effect when the SDK work faults. + /// The owning activation lifetime, when the caller has one. + /// The dispatcher failure or the translated SDK fault. + /// Observed by the dispatcher before admission. + /// when the work ran to completion without an SDK fault. + /// + /// Lifecycle exceptions from the dispatcher (, + /// ) and Client exceptions raised by + /// propagate unchanged. + /// + internal static bool TryInvoke(ICheatEngineDispatcher dispatcher, string operation, Action work, + CheatEngineHostEffect hostEffectOnFault, CoreLifetime? lifetime, out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(work); + + Exception? fault = null; + if (!dispatcher.TryInvoke(() => + { + try + { + work(); + } + catch (Exception exception) when (IsSdkFault(exception)) + { + fault = exception; + } + }, out failure, cancellationToken)) + { + return false; + } + + if (fault is null) + { + return true; + } + + failure = Translate(operation, fault, hostEffectOnFault, lifetime); + return false; + } + + /// Throws the activation-expired exception when an SDK fault was observed after the activation ended. + /// The activation is no longer current. + internal static void ThrowIfActivationEnded(string operation, Exception exception, CoreLifetime? lifetime) + { + if (lifetime is { IsActivationCurrent: false }) + { + new CheatEngineFailure(CheatEngineFailureKind.ActivationExpired, operation, + "The Cheat Engine plugin lifecycle changed while Client work was calling the SDK.", exception).Throw(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/SdkReleaseOutcomes.cs b/libs/CheatEngine.Client.Core/Infrastructure/SdkReleaseOutcomes.cs new file mode 100644 index 0000000..c974b95 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/SdkReleaseOutcomes.cs @@ -0,0 +1,163 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// Maps every release status that CheatEngine.SDK 2.0.0 reports to the one Client lease vocabulary +/// (), and combines the outcomes of a lease made of several parts. +/// +/// +/// +/// Each mapping is total over its SDK enum. A value this Client version does not know is +/// with an effect: an +/// unrecognized status never reads as a release. The mapping-totality tests fail when the consumed SDK adds a +/// value. +/// +/// +/// The host effect records how far the release call got: for a +/// confirmed release, for a call that began without a confirmed +/// result, and when no release call was made. +/// +/// +/// The SDK's NotInvoked consumes a target-bound owner, yet it maps to the retryable +/// (the same rule as the SDK's symbol leases): a later attempt +/// through a consumed owner returns the same status without any Cheat Engine call, and the activation cleanup +/// reports it if it is still unavailable at deactivation. +/// +/// +/// The SDK Lua registration lease is not mapped here: the generated Lua registrar owns it and maps its +/// LuaRegistrationReleaseKind in the consumer's assembly. +/// +/// +internal static class SdkReleaseOutcomes +{ + /// Maps the release status of a target-bound SDK owner (Owned<T>, memory-scan owners). + /// The status reported by CheatEngine.SDK. + /// The Client outcome; for an unrecognized value. + internal static LeaseReleaseOutcome FromTarget(TargetReleaseStatus status) + { + return status switch + { + // No release was attempted yet: the owner is still held, so the lease stays retryable. + TargetReleaseStatus.Unspecified => Outcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.NotStarted), + TargetReleaseStatus.Released => Outcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + TargetReleaseStatus.RefusedNoTarget => Outcome(LeaseReleaseKind.RefusedTargetNotAttached, + CheatEngineHostEffect.NotStarted), + TargetReleaseStatus.RefusedIdentityUnavailable => Outcome(LeaseReleaseKind.RefusedTargetIdentityUnavailable, + CheatEngineHostEffect.NotStarted), + TargetReleaseStatus.RefusedTargetChanged => Outcome(LeaseReleaseKind.RefusedTargetChanged, + CheatEngineHostEffect.NotStarted), + // A reused PID names another process incarnation: for the Client that is a change of target. + TargetReleaseStatus.RefusedProcessReused => Outcome(LeaseReleaseKind.RefusedTargetChanged, + CheatEngineHostEffect.NotStarted), + TargetReleaseStatus.UnconfirmedAfterInvocation => Outcome(LeaseReleaseKind.CleanupUnconfirmed, + CheatEngineHostEffect.Started), + TargetReleaseStatus.NotInvoked => Outcome(LeaseReleaseKind.CleanupUnavailable, + CheatEngineHostEffect.NotStarted), + TargetReleaseStatus.RefusedRuntimeChanged => Outcome(LeaseReleaseKind.RefusedRuntimeChanged, + CheatEngineHostEffect.NotStarted), + _ => Unrecognized() + }; + } + + /// Maps the release kind of an SDK symbol or symbol-list registration lease. + /// The kind reported by CheatEngine.SDK. + /// The Client outcome; for an unrecognized value. + internal static LeaseReleaseOutcome FromSymbolRegistration(SymbolRegistrationReleaseKind kind) + { + return kind switch + { + SymbolRegistrationReleaseKind.Unknown => Unrecognized(), + SymbolRegistrationReleaseKind.Released => Outcome(LeaseReleaseKind.Released, + CheatEngineHostEffect.Completed), + SymbolRegistrationReleaseKind.AlreadyReleased => Outcome(LeaseReleaseKind.AlreadyReleased, + CheatEngineHostEffect.NotStarted), + SymbolRegistrationReleaseKind.Superseded => Outcome(LeaseReleaseKind.Superseded, + CheatEngineHostEffect.NotStarted), + SymbolRegistrationReleaseKind.StaleRuntime => Outcome(LeaseReleaseKind.RefusedRuntimeChanged, + CheatEngineHostEffect.NotStarted), + SymbolRegistrationReleaseKind.CleanupUnavailable => Outcome(LeaseReleaseKind.CleanupUnavailable, + CheatEngineHostEffect.NotStarted), + SymbolRegistrationReleaseKind.CleanupIndeterminate => Outcome(LeaseReleaseKind.CleanupUnconfirmed, + CheatEngineHostEffect.Started), + SymbolRegistrationReleaseKind.Replaced => Outcome(LeaseReleaseKind.Replaced, + CheatEngineHostEffect.NotStarted), + SymbolRegistrationReleaseKind.ExternallyRemoved => Outcome(LeaseReleaseKind.ExternallyRemoved, + CheatEngineHostEffect.NotStarted), + _ => Unrecognized() + }; + } + + /// Combines the outcomes of two parts of one lease, keeping the worse of the two. + /// The outcome of one part. + /// The outcome of the other part. + /// The combined outcome; the operation is commutative. + /// + /// + /// The worse kind is the one that leaves more to do. A retryable kind ranks highest, because the lease must + /// stay active so that the part that can still be released is retried; a kind that requires manual recovery + /// ranks next (an unconfirmed call above a partial release, above a refusal); a complete kind ranks lowest. + /// An unrecognized kind ranks like . + /// + /// + /// The host effects combine separately: equal effects stay, wins, + /// then ; any other mix means that part of the release + /// ran and part did not, which is . + /// + /// + internal static LeaseReleaseOutcome Worst(LeaseReleaseOutcome first, LeaseReleaseOutcome second) + { + LeaseReleaseKind kind = Rank(first.Kind) >= Rank(second.Kind) ? first.Kind : second.Kind; + return new LeaseReleaseOutcome(kind, CombineEffects(first.HostEffect, second.HostEffect)); + } + + /// Orders the kinds from the one that leaves nothing to do to the one that leaves the most. + private static int Rank(LeaseReleaseKind kind) + { + return kind switch + { + LeaseReleaseKind.Released => 0, + LeaseReleaseKind.AlreadyReleased => 1, + LeaseReleaseKind.Superseded => 2, + LeaseReleaseKind.Replaced => 3, + LeaseReleaseKind.ExternallyRemoved => 4, + LeaseReleaseKind.RefusedTargetNotAttached => 5, + LeaseReleaseKind.RefusedTargetIdentityUnavailable => 6, + LeaseReleaseKind.RefusedTargetChanged => 7, + LeaseReleaseKind.RefusedRuntimeChanged => 8, + LeaseReleaseKind.PartiallyReleased => 9, + LeaseReleaseKind.CleanupUnconfirmed => 10, + LeaseReleaseKind.CleanupUnavailable => 11, + _ => 12 + }; + } + + private static CheatEngineHostEffect CombineEffects(CheatEngineHostEffect first, CheatEngineHostEffect second) + { + if (first == second) + { + return first; + } + + if (first == CheatEngineHostEffect.Unknown || second == CheatEngineHostEffect.Unknown) + { + return CheatEngineHostEffect.Unknown; + } + + return first == CheatEngineHostEffect.CleanupUnconfirmed || second == CheatEngineHostEffect.CleanupUnconfirmed + ? CheatEngineHostEffect.CleanupUnconfirmed + : CheatEngineHostEffect.Started; + } + + private static LeaseReleaseOutcome Outcome(LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + return new LeaseReleaseOutcome(kind, hostEffect); + } + + private static LeaseReleaseOutcome Unrecognized() + { + return new LeaseReleaseOutcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs index 0ecb6a2..f00ed43 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs @@ -1,5 +1,3 @@ -using CheatEngine.Client.Results; - namespace CheatEngine.Client.Core.Infrastructure; /// @@ -20,7 +18,16 @@ internal sealed class TargetSelectionLifetime(Action activationGuard) : internal long Epoch => Volatile.Read(ref _epoch); /// Releases every target-bound resource that remains at activation shutdown. + /// Several resources failed to release; the inner exceptions keep attempt order. public void Dispose() + { + List failures = []; + DisposeCollecting(failures); + CoreResourceRegistry.ThrowCleanupFailures(failures); + } + + /// Releases every remaining target-bound resource and appends each failure in attempt order. + internal void DisposeCollecting(List failures, Action? onFailure = null) { IDisposable[] resources; lock (_gate) @@ -34,7 +41,7 @@ public void Dispose() resources = _resources.DetachAll(); } - CoreResourceRegistry.DisposeDetached(resources); + CoreResourceRegistry.DisposeDetached(resources, failures, onFailure); } /// @@ -75,7 +82,7 @@ internal void ThrowIfExpired(long capturedEpoch, string operation) return; } - throw new CheatEngineClientLifecycleException(operation, + throw ClientExceptions.InvalidState(operation, "The target process selection changed, so this resource is no longer valid."); } } @@ -88,7 +95,7 @@ internal T Track(T resource, long capturedEpoch) lock (_gate) { - ThrowIfExpiredCore(capturedEpoch, "TargetSelection.Track"); + ThrowIfExpiredCore(capturedEpoch, "Client.TrackResource"); return _resources.Track(resource, capturedEpoch); } } @@ -113,7 +120,7 @@ private void ThrowIfExpiredCore(long capturedEpoch, string operation) return; } - throw new CheatEngineClientLifecycleException(operation, + throw ClientExceptions.InvalidState(operation, "The target process selection changed, so this resource is no longer valid."); } diff --git a/libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs b/libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs new file mode 100644 index 0000000..261cb88 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs @@ -0,0 +1,22 @@ +namespace CheatEngine.Client.Core.Qualification; + +/// +/// The host qualification evidence this Client build embeds: data only, read by . +/// +/// +/// +/// It is empty until a live qualification run is recorded: the commit that records a run's redacted evidence +/// under tests/CheatEngine.Client.Tests/LiveQualification/Evidence fills it with that run's id, the Client +/// version, the exact CheatEngine.SDK identity, the host profile and, per capability, the passed and waived +/// scenarios and the qualified target architectures. Nothing else writes it. +/// +/// +/// This file is excluded from the qualified source digest (QualifiedSourceDigest), so recording the evidence +/// does not change the digest of the sources the run qualified. +/// +/// +internal static class HostQualificationEvidence +{ + /// Gets the recorded evidence, or while no run is recorded. + internal static HostQualificationRecord? Recorded => null; +} diff --git a/libs/CheatEngine.Client.Core/Qualification/HostQualificationGate.cs b/libs/CheatEngine.Client.Core/Qualification/HostQualificationGate.cs new file mode 100644 index 0000000..66b9414 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Qualification/HostQualificationGate.cs @@ -0,0 +1,215 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Qualification; + +/// The host qualification one recorded live run established for one Client capability. +/// The capability. +/// The scenarios of the capability whose every receipt of the run passed. +/// The scenarios of the capability that carry a dated waiver instead of a pass. +/// The target architectures the run qualified the capability on. +internal sealed record HostQualifiedCapability( + ClientCapabilityId Capability, + ImmutableArray PassedScenarios, + ImmutableArray WaivedScenarios, + ImmutableArray Architectures); + +/// +/// The evidence of one recorded live qualification run (tests/CheatEngine.Client.Tests/LiveQualification/Evidence), +/// embedded as data in . +/// +/// The id of the recorded run. +/// The Client version the run qualified, without build metadata. +/// The exact CheatEngine.SDK identity the run used (version+commit). +/// The host profile id of the run. +/// The Cheat Engine file version of the run. +/// The capabilities the run qualified. +internal sealed record HostQualificationRecord( + string RunId, + string ClientVersion, + string SdkInformationalVersion, + string HostProfile, + CheatEngineVersion CheatEngineVersion, + ImmutableArray Capabilities); + +/// The facts of the running host and target the qualification gate compares with the evidence. +/// Whether the loaded CheatEngine.SDK.Engine is exactly the reviewed package. +/// +/// The informational version of the loaded CheatEngine.SDK.Engine (version+commit), or +/// . +/// +/// The observed Cheat Engine file version, or . +/// +/// The width of Cheat Engine's own process, when it was not observed. +/// +/// The observed host operating system. +/// How Cheat Engine reaches the selected target. +/// The architecture of the selected target. +/// This Client's version without build metadata, or . +internal readonly record struct HostQualificationContext( + bool ExactReviewedIdentity, + string? SdkInformationalVersion, + CheatEngineVersion? CheatEngineVersion, + PointerSize CheatEngineBitness, + CheatEngineOperatingSystem OperatingSystem, + TargetBackend Backend, + CheatEngineArchitecture TargetArchitecture, + string? ClientVersion); + +/// +/// The qualification gate of a Client capability, derived from the host evidence this build embeds +/// (), never from a claim in the source (audit A20-19). +/// +/// +/// +/// The gate is only when every condition holds: evidence +/// is embedded; the loaded CheatEngine.SDK.Engine is exactly the reviewed package +/// () and the evidence names that same identity; the +/// evidence records Cheat Engine 7.7.0.10621 and the host profile this Client supports +/// (); the observed host is Cheat Engine 7.7.0.10621, +/// 64-bit, on Windows; the target is a local process whose architecture the run qualified for the capability; +/// this Client's version equals the version the evidence recorded; and every scenario the capability requires +/// () passed in the run, none of them waived. Otherwise the gate is +/// , with the first condition that does not hold as its +/// reason: a missing qualification is not evidence that the capability fails. +/// +/// +/// Client.UnsafeLuaExecution requires no scenario and is never qualified. +/// +/// +internal static class HostQualificationGate +{ + /// The only Cheat Engine file version a qualification covers. + internal static readonly CheatEngineVersion QualifiedCheatEngineVersion = CheatEngineVersion.Ce77010621; + + /// Evaluates the qualification gate of one catalog row. + /// The capability's catalog row. + /// The embedded evidence, or when this build embeds none. + /// The observed host and target facts. + /// The reason when no evidence is embedded. + /// The gate. + internal static ClientCapabilityEvidenceGate Evaluate(ClientCapabilityDescriptor entry, HostQualificationRecord? evidence, + HostQualificationContext context, string noEvidenceReason) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentException.ThrowIfNullOrWhiteSpace(noEvidenceReason); + string capability = entry.Id.Value; + if (entry.RequiredScenarios.IsDefaultOrEmpty) + { + return Unknown($"{capability} is never host-qualified: no live scenario covers it."); + } + + if (evidence is null) + { + return Unknown(noEvidenceReason); + } + + string loadedSdk = context.SdkInformationalVersion ?? "no informational version"; + if (!context.ExactReviewedIdentity) + { + return Unknown($"The loaded CheatEngine.SDK.Engine ({loadedSdk}) is not exactly the CheatEngine.SDK package " + + "this Client build reviewed."); + } + + if (!string.Equals(context.SdkInformationalVersion, evidence.SdkInformationalVersion, StringComparison.Ordinal)) + { + return Unknown($"Run {evidence.RunId} used CheatEngine.SDK {evidence.SdkInformationalVersion}, not the " + + $"loaded CheatEngine.SDK.Engine ({loadedSdk})."); + } + + if (evidence.CheatEngineVersion != QualifiedCheatEngineVersion) + { + return Unknown($"Run {evidence.RunId} recorded Cheat Engine {evidence.CheatEngineVersion}; a qualification " + + $"covers Cheat Engine {QualifiedCheatEngineVersion} only."); + } + + if (!string.Equals(evidence.HostProfile, ConsumedSdkIdentity.SupportedHostProfileId, StringComparison.Ordinal)) + { + return Unknown($"Run {evidence.RunId} recorded the host profile {evidence.HostProfile}; this Client " + + $"supports {ConsumedSdkIdentity.SupportedHostProfileId} only."); + } + + if (context.CheatEngineVersion != QualifiedCheatEngineVersion || + context.CheatEngineBitness != PointerSize.Bit64 || + context.OperatingSystem != CheatEngineOperatingSystem.Windows) + { + string bitness = context.CheatEngineBitness.IsKnown + ? $"{context.CheatEngineBitness.Bytes * 8}-bit" + : "an unknown width"; + return Unknown($"The host is not Cheat Engine {QualifiedCheatEngineVersion} 64-bit on Windows (observed " + + $"{context.CheatEngineVersion?.ToString() ?? "an unknown version"}, {bitness}, " + + $"{context.OperatingSystem})."); + } + + if (context.Backend != TargetBackend.LocalProcess) + { + return Unknown($"The selected target is reached through {context.Backend}; run {evidence.RunId} qualified local " + + "processes only."); + } + + if (!string.Equals(context.ClientVersion, evidence.ClientVersion, StringComparison.Ordinal)) + { + return Unknown($"This Client build ({context.ClientVersion ?? "no version"}) is not the Client " + + $"{evidence.ClientVersion} that run {evidence.RunId} qualified."); + } + + HostQualifiedCapability? qualified = null; + foreach (HostQualifiedCapability candidate in evidence.Capabilities) + { + if (candidate.Capability == entry.Id) + { + qualified = candidate; + break; + } + } + + if (qualified is null) + { + return Unknown($"Run {evidence.RunId} recorded no qualification of {capability}."); + } + + if (!qualified.Architectures.Contains(context.TargetArchitecture)) + { + return Unknown($"Run {evidence.RunId} did not qualify {capability} on a {context.TargetArchitecture} target."); + } + + foreach (string scenario in entry.RequiredScenarios) + { + if (qualified.WaivedScenarios.Contains(scenario)) + { + return Unknown($"Scenario {scenario} of {capability} is waived in run {evidence.RunId}; a waiver never " + + "qualifies a capability."); + } + + if (!qualified.PassedScenarios.Contains(scenario)) + { + return Unknown($"Scenario {scenario} of {capability} did not pass in run {evidence.RunId}."); + } + } + + return new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied, + $"Run {evidence.RunId} qualified {capability} for Client {evidence.ClientVersion} on Cheat Engine " + + $"{QualifiedCheatEngineVersion} x64 ({evidence.HostProfile}) with CheatEngine.SDK {evidence.SdkInformationalVersion}."); + } + + /// A version without its build metadata (1.0.0+abc is 1.0.0), or . + internal static string? WithoutMetadata(string? informationalVersion) + { + if (string.IsNullOrWhiteSpace(informationalVersion)) + { + return null; + } + + int plus = informationalVersion.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? informationalVersion : informationalVersion[..plus]; + } + + private static ClientCapabilityEvidenceGate Unknown(string reason) + { + return new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Unknown, reason); + } +} diff --git a/libs/CheatEngine.Client.Core/README.md b/libs/CheatEngine.Client.Core/README.md index 0dc9ee5..7dd1e3d 100644 --- a/libs/CheatEngine.Client.Core/README.md +++ b/libs/CheatEngine.Client.Core/README.md @@ -1,27 +1,19 @@ # CheatEngine.Client.Core -## Context - -`CheatEngine.Client.Core` is the SDK-facing implementation layer of `CheatEngine.Client`. It maps -the public contracts from `CheatEngine.Client.Abstractions` onto `CheatEngine.SDK` 1.x while a -Cheat Engine plugin activation is current. - -This package contains the main-thread dispatcher adapter, runtime probes, target-selection -tracking, typed memory and AOB adapters, copied inspection/table operations, protected Lua -operations, and lifecycle-owned cleanup infrastructure. It is an in-process layer for local, -authorized targets; it is not an IPC client or a standalone Cheat Engine host. +The SDK-facing implementation of [CheatEngine.Client](https://www.nuget.org/packages/CheatEngine.Client). It is not a +standalone package: plugins never reference it directly. -## Why This Project Exists +## Context -The Client must use the SDK directly for its real runtime work, but its SDK-specific ownership and -Lua details must not become application-level implementation concerns. Core provides that boundary: -it translates stable contracts to SDK calls, normalizes expected failures, and owns the rules that -make an enable/disable/re-enable cycle safe. +`CheatEngine.Client.Core` maps the public contracts of `CheatEngine.Client.Abstractions` onto `CheatEngine.SDK` 2.x +while a Cheat Engine plugin activation is current: main-thread dispatch, runtime and target facts, target-selection +tracking, typed memory, AOB and value scans, copied inspection and Address List operations, protected Lua, target +allocations, instructions, Auto Assembler patches and the cleanup of every Client-owned resource. It is an in-process +layer for local, authorized targets; it is not an IPC client or a standalone Cheat Engine host. -Core references `CheatEngine.Client.Abstractions` and the full SDK package. It never references -`CheatEngine.Client.Fluent`. The Dependency Injection and Hosting packages compose Core into the -activation-scoped `ICheatEngineClient`; applications should obtain that facade through hosting or -DI instead of constructing Core services. +`ICheatEngineClient` is the governing contract of that in-process model: it runs inside an enabled Cheat Engine plugin +on Cheat Engine's main thread. It is not a `ceserver` network client, not Cheat Engine's `luaclient` (CELUA) library, +and not a universal RPC client. ```text Abstractions ← Core ← DependencyInjection ← Hosting @@ -31,67 +23,104 @@ Abstractions ← Core ← DependencyInjection ← Hosting Abstractions ← Fluent ``` -## How It Improves CheatEngine.Client - -- Sends every Cheat Engine operation through the synchronous SDK main-thread dispatcher. -- Captures an SDK activation epoch and rejects stale work instead of letting a resource survive a - disable/re-enable boundary. -- Closes ordinary work admission during disable, then drains Client-owned resources on the CE main - thread in reverse creation order while the SDK context is still valid. -- Separates the plugin epoch from the selected-target epoch so target changes invalidate only - target-bound resources. -- Copies SDK-owned scan/list data into managed values before releasing the owner; the public API - never leaks SDK Lua state, CE objects, or `Owned` wrappers. -- Maps predictable host failures to `CheatEngineFailure` and preserves ordinary .NET exceptions - for programming errors. - -## Ownership and Delivery Boundary - -Core is a delivery package, not a second public facade. Its concrete service implementations and -infrastructure are internal; the supported public contracts remain in functional namespaces from -the Abstractions package such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, and -`.Runtime`. Do not add consumer namespaces such as `CheatEngine.Client.Core`. - -The package enables neither an SDK plugin entry point nor dynamic loading on its own. The final -plugin project must directly reference both `CheatEngine.Client` and `CheatEngine.SDK` so that the -SDK generator, build assets, native Lua bridge, and host bootstrap execute at the actual plugin -boundary. - -## Capability Status and Limits - -Core implements the currently qualified Client mappings for runtime facts and capabilities, -process selection, typed memory, bounded pointer chains and strings, AOB scans, copied inspection, -Address List operations, trusted table paths, and protected typed Lua work. All CE calls remain -synchronous; a cancellation token is observed before dispatch or between Client-managed steps, not -as an interruption of an already-running Lua primitive. - -Value scan creation is deliberately unavailable. Although the public session contract and state -machine are present, CheatEngine.SDK 1.0.0 does not expose a public owner factory for the -`MemScan` and `FoundList` instances that Client would need. Core reports a capability failure -rather than making an unverified ownership assumption. It must not be documented or released as -a live value-scanning implementation until the Cheat Engine 7.7 x64 creation, destruction, -disable, and reactivation gate passes. - -UI/forms, debugger and breakpoints, Auto Assembler, injection, public remote allocations, -structures, hotkeys/timers, speedhack, DBVM, Mono/IL2CPP, and ABI hooks are outside this layer's -current delivered capability. - -## Contribution and Validation - -Treat lifetime, dispatch, and disposal changes as host-safety changes. Keep SDK handles internal, -route new CE work through the dispatcher, add a capability observation for optional bindings, and -test target/activation invalidation and reverse-order cleanup in -`tests/CheatEngine.Client.Core.Tests`. - -Core's direct public baseline is intentionally empty; changes that create a public type require an -explicit product-surface decision and corresponding `PublicAPI.Unshipped.txt` update. Validate the -repository from its root: - -```powershell -dotnet restore CheatEngine.Client.slnx --locked-mode -dotnet build CheatEngine.Client.slnx --configuration Release --no-restore -dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore -``` - -The ordinary test suite uses SDK-facing ports/fakes and does not replace the opt-in live Cheat -Engine qualification gate. +## Installation + +Reference [`CheatEngine.Client`](https://www.nuget.org/packages/CheatEngine.Client), never Core on its own: the +[CheatEngine.Client README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/src/CheatEngine.Client/README.md) +gives the plugin project, the requirements (`net10.0`, C# 14, a .NET SDK 10.0.401 or later, Cheat Engine 7.7.0.10621 +x64, a direct `CheatEngine.SDK` reference in `[2.0.0, 3.0.0)`) and a minimal plugin. Hosting composes Core into the +activation-scoped `ICheatEngineClient`; a plugin obtains that facade from Hosting and never constructs a Core service. + +Core has no public API. `CheatEngine.Client.Extensions.DependencyInjection` and `CheatEngine.Client.Hosting` use its +internal types through `InternalsVisibleTo`, and it is published only as a dependency of +`CheatEngine.Client.Extensions.DependencyInjection`. The seven Client packages ship in lockstep, with one version, and +each depends on the Client packages it builds on at exactly that version (`[X.Y.Z]` in its nuspec): a Core of another +version than the packages that call its internals is never a supported combination. The Client assemblies are not +strong-named, so an `InternalsVisibleTo` grant names an assembly, not a signing key; internal members are not a +contract and change in any release. + +The package enables neither an SDK plugin entry point nor dynamic loading on its own. The plugin project references +both `CheatEngine.Client` and `CheatEngine.SDK` directly, so that the SDK generator, build assets, native Lua bridge and +host bootstrap run at the actual plugin boundary. + +## What Core guarantees to a plugin + +- Every Cheat Engine operation goes through the synchronous SDK main-thread dispatcher. +- An activation epoch is captured when the plugin is enabled, and stale work is refused instead of letting a resource + survive a disable/re-enable boundary. +- Disable closes ordinary work admission, then releases Client-owned resources on Cheat Engine's main thread in reverse + creation order while the SDK context is still valid. A lease-owned resource is created only while the activation is + active, never from the cleanup scope. +- The plugin epoch and the target-selection epoch are separate, so a target change ends only the target-bound + resources. +- SDK-owned scan and list data are copied into managed values before their owner is released: no SDK Lua state, + Cheat Engine object or `Owned` wrapper reaches plugin code. +- Expected host failures become a `CheatEngineFailure`, classified by exception type and the SDK's own status values, + never by message text; programming errors stay ordinary .NET exceptions, and exceptions of your own callbacks, codecs + and Lua operations are rethrown unchanged. +- Every runtime and target fact is a read-only CheatEngine.SDK 2.0.0 observation: a snapshot never loads a driver, runs + remote code, changes the target, loads a table or allocates target memory. + +The domains that no CheatEngine.SDK primitive backs (timers, hotkeys, the debugger, the speed hack, hashing, DBVM and +remote execution) have no Client contract and nothing in Core, as the +[Abstractions README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md#not-offered-in-10) +states. + +## Capability status + +Every capability composes one operational adapter, so every implementation gate is satisfied. No Client qualification +receipt exists yet, so every qualification gate reports `Unknown` and no capability reports `Available`. The package +gate compares the CheatEngine.SDK identity this build consumed with the `CheatEngine.SDK.Engine` assembly actually +loaded: it is `Satisfied` for a release of the supported major at or above the consumed version, by SemVer precedence, +and its reason says whether the loaded assembly is exactly the reviewed package. The per-capability evidence and the +experimental APIs are in the +[Abstractions README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md#capability-boundary). + +## Cost of Cheat Engine calls + +Every Client operation is synchronous and runs on Cheat Engine's main thread. A cancellation token is observed before +dispatch and between Client-managed steps; it never interrupts a Lua primitive that has already started. + +- **Pointer-typed memory.** Every `ReadPrimitive
` or `WritePrimitive
` call, every `Address` primitive + batch and every pointer chain observes the target facts once inside its dispatched call: about nine Lua global calls + in one Lua admission (the selected process identifier twice, `isConnectedToCEServer`, `targetIs64Bit`, `targetIsX86`, + `targetIsArm`, `targetIsAndroid`, `getABI`, `getPointerSize`). A batch pays this once for all its items, so hot + pointer-read loops should use batches or explicit 32- or 64-bit integer reads. +- **AOB scans.** A request without a module or range runs one global `AOBScan` over the whole target. With a module or + a range, a qualified local target runs an exhaustive MemScan limited to the module intersected with the range, which + blocks Cheat Engine's main thread and cannot be interrupted once started; another target runs the global scan and the + Client applies the module and range while copying, which does not reduce Cheat Engine's work. `MaximumResults` + bounds only the copy. `IPatternScanner.ScanDetailed` measures the Cheat Engine scan and the Client copy separately. +- **Address List snapshots** copy the records up to the caller's limit; the child count of a record is still read + through Cheat Engine's `Count` property. + +## Diagnostics events + +Core has no logging dependency. It reports bounded events to an internal sink carried by the +activation lifetime; `CheatEngine.Client.Extensions.DependencyInjection` writes them through +`Microsoft.Extensions.Logging` with source-generated events and one category per domain, so the +standard `Logging:LogLevel` filters select them: + +| Event id | Level | Event | Category | +|---|---|---|---| +| 1000 | Debug | `RuntimeSnapshotCaptured`: activation epoch, target architecture, process and configured pointer bytes, mismatch flag | `CheatEngine.Client.Runtime` | +| 1001 | Debug | `CapabilityRefused`: capability id, operation, gate, gate state; once per capability and operation per activation | `CheatEngine.Client.Runtime` | +| 1100 | Debug | `TargetSelectionAdvanced`: activation and selection epochs, operation, reason (`PidChanged`, `ProcessReused`, `BackendChanged`, `ArchitectureChanged`, `WidthChanged`, `TargetDetached`) | `CheatEngine.Client.Processes` | +| 1200 | Information | `PointerWidthMismatchRefused`: operation, process and configured pointer bytes | `CheatEngine.Client.Memory` | +| 1201 | Debug | `MemoryBatchCompleted`: operation, requested and completed counts, effect state | `CheatEngine.Client.Memory` | +| 1300 | Debug | `TableGenerationAdvanced`: activation epoch, table generation | `CheatEngine.Client.Tables` | +| 1301 | Debug | `StaleRecordIdentifierRefused`: operation, table generation | `CheatEngine.Client.Tables` | +| 1302 | Debug | `RecordActivationNotApplied`: operation, requested state, status (`RefusedByHost`, `Pending`, `Indeterminate`) | `CheatEngine.Client.Tables` | +| 1400 | Debug | `SymbolRegistrationRejected`: operation, reason (`AlreadyResolves`, `LookupFailed`) | `CheatEngine.Client.Inspection` | +| 1500 | Debug | `PatternScanCompleted`: scope, host result and materialized counts, truncation, scan and copy milliseconds | `CheatEngine.Client.Scanning` | +| 1600 | Debug | `LuaOperationCompleted`: operation, failure kind or `None`, milliseconds, script length (unsafe Lua only) | `CheatEngine.Client.Lua` | +| 1700 | Warning | `CoreResourceCleanupFailed`: resource and exception type names | `CheatEngine.Client.Lifetime` | +| 1701 | Debug | `LeaseReleased`: release operation, `LeaseReleaseKind`, host effect | `CheatEngine.Client.Lifetime` | +| 1800 | Warning | `AutoAssemblerPatchAppliedAfterTargetChange`: operation, selection epoch the patch stays bound to | `CheatEngine.Client.Assembly` | + +Events are emitted after the dispatched Cheat Engine work returned, never inside a dispatched +callback. They never carry an address, a value, a symbol or module name, a path, a process +identifier or name, a Lua script or its text, an exception message or a failure object. A logger +or provider that throws is contained and never changes an operation result or a cleanup. Hosting adds its own +activation and cleanup events, described in the +[Hosting README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Hosting/README.md#cleanup-diagnostics-and-redaction). diff --git a/libs/CheatEngine.Client.Core/packages.lock.json b/libs/CheatEngine.Client.Core/packages.lock.json index 48b14cb..2eef4ee 100644 --- a/libs/CheatEngine.Client.Core/packages.lock.json +++ b/libs/CheatEngine.Client.Core/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "CheatEngine.SDK": { "type": "Direct", - "requested": "[1.0.0, 2.0.0)", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, 3.0.0)", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", @@ -20,41 +20,24 @@ "resolved": "10.0.12", "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, - "Microsoft.SourceLink.GitHub": { + "Microsoft.Sbom.Targets": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + "requested": "[4.1.13, )", + "resolved": "4.1.13", + "contentHash": "l9NiCqVmBBY06Lrxv61xWtiLvU1feto6j7QmMsaARBopO+QLTFDFLEIbYe8pYGjk1INJ2eWE/GGjToqQpokYAw==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" }, "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } } } } -} +} \ No newline at end of file diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/AutoAssemblerPatchesRegistration.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/AutoAssemblerPatchesRegistration.cs new file mode 100644 index 0000000..129e98a --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/AutoAssemblerPatchesRegistration.cs @@ -0,0 +1,10 @@ +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Records the builder-only opt-in required to enable Auto Assembler patches for an activation. +internal sealed class AutoAssemblerPatchesRegistration +{ + internal bool IsEnabled + { + get; + } = true; +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj index 776458e..471982d 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj @@ -1,10 +1,17 @@ - {5E8B0B81-9BCB-4505-AF18-6071D762CC91} true + Microsoft.Extensions.DependencyInjection registrations for CheatEngine.Client, the composition layer of CheatEngine.Client.Hosting: the activation-scoped client, modules and validated options. + + $(NoWarn);CECLIENT5001;CECLIENT5002;CECLIENT5003;CECLIENT5004 + + + + + diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs index 26ff9ea..5d4ab2c 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs @@ -8,6 +8,8 @@ internal sealed class CheatEngineClientActivationCleanup(CoreLifetime lifetime) { private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + public bool ExternalLuaStateResetDetected => SdkBoundary.ExternalStateResetDetected; + public IDisposable EnterCleanupScope() { return _lifetime.EnterCleanupScope(); diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs index d7d47e0..a04ef72 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs @@ -1,10 +1,11 @@ using System.Diagnostics.CodeAnalysis; +using CheatEngine.Client.Assembly; using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.Assembly; using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Lua; -using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using Microsoft.Extensions.Configuration; @@ -15,8 +16,17 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// Configures explicit registrations for one Cheat Engine client service provider. /// -/// The builder never constructs a service provider. Plugin hosting creates and validates one provider for each Cheat -/// Engine activation epoch, after all registrations are complete. +/// +/// The builder never constructs a service provider. CheatEngine.Client.Hosting creates and validates one provider +/// for each Cheat Engine activation epoch, after all registrations are complete, and hands this builder to the +/// plugin as CheatEnginePluginBuilder.Client. This package is the composition layer of Hosting: composing +/// the Client in a provider that Hosting does not own is not supported in 1.0. +/// +/// +/// No memory codec is registered or resolved implicitly. A plugin registers its own codec as an ordinary +/// service, for example Services.AddSingleton<IMemoryCodec<T>, TCodec>(), and passes it to +/// IMemoryClient through MemoryReadRequest<T> or MemoryWriteRequest<T>. +/// /// public sealed class CheatEngineClientBuilder { @@ -32,6 +42,9 @@ public IServiceCollection Services } /// Adds a programmatic options configuration that runs after configuration binding. + /// Configures the options of every activation. + /// This builder. + /// is . public CheatEngineClientBuilder Configure(Action configure) { ArgumentNullException.ThrowIfNull(configure); @@ -40,6 +53,13 @@ public CheatEngineClientBuilder Configure(Action confi } /// Binds client options from the default client section of a configuration root. + /// + /// The configuration whose section is bound. + /// + /// This builder. + /// + /// is . + /// public CheatEngineClientBuilder BindConfiguration(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); @@ -47,6 +67,9 @@ public CheatEngineClientBuilder BindConfiguration(IConfiguration configuration) } /// Binds client options from an explicitly selected configuration section. + /// The configuration section bound to the options. + /// This builder. + /// is . public CheatEngineClientBuilder BindConfiguration(IConfigurationSection section) { ArgumentNullException.ThrowIfNull(section); @@ -56,6 +79,7 @@ public CheatEngineClientBuilder BindConfiguration(IConfigurationSection section) /// Adds one activation module in registration order. /// The concrete module type. + /// This builder. /// /// Module construction is explicit through the generic service descriptor; no assembly scanning or runtime type /// discovery is performed. Modules are scoped to the activation so they can depend on other scoped application @@ -63,26 +87,25 @@ public CheatEngineClientBuilder BindConfiguration(IConfigurationSection section) /// public CheatEngineClientBuilder AddModule< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] - TModule>() + TModule>() where TModule : class, ICheatEngineClientModule { Services.TryAddEnumerable(ServiceDescriptor.Scoped()); return this; } - /// Adds one descriptor-backed Lua module to every Client activation. - /// The generated or explicitly described Lua module type. + /// Adds one Lua module to every Client activation. + /// The generated () or manual Lua module type. + /// This builder. /// /// The module is created from its public constructor by the activation-scoped provider and is registered only - /// after the Client and Lua runtime are live. Its lease is released in reverse module order during disable. This - /// method intentionally accepts only described modules: manual implementations remain - /// available through , but cannot participate in the Client-wide export - /// collision guarantee because they do not publish an immutable descriptor. + /// after the Client and Lua runtime are live, after the Client reserved its descriptor's module name and exports + /// for the activation. Its lease is released in reverse module order during disable. /// public CheatEngineClientBuilder AddLuaModule< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] - TModule>() - where TModule : class, IDescribedLuaModule + TModule>() + where TModule : class, ILuaModule { Services.TryAdd(ServiceDescriptor.Describe(typeof(TModule), typeof(TModule), ServiceLifetime.Scoped)); Services.TryAddEnumerable(ServiceDescriptor.Describe( @@ -92,23 +115,11 @@ public CheatEngineClientBuilder AddLuaModule< return this; } - /// Adds a singleton, deterministic codec for a managed memory value type. - /// The managed memory value type. - /// The concrete codec type. - /// - /// Codecs must not capture a Lua state, CE object, activation scope, or target-specific state. The default codecs - /// cover only fixed-width scalar and pointer representations; variable-length memory is deliberately opt-in. - /// - public CheatEngineClientBuilder AddMemoryCodec() - where TCodec : class, IMemoryCodec - { - Services.TryAdd(ServiceDescriptor.Singleton, TCodec>()); - return this; - } - /// Opts this activation into trusted arbitrary Lua execution. + /// This builder. + /// + /// was registered by another path than this method. + /// /// /// This is the only supported opt-in path. Configuration binding cannot enable the capability or register the /// unsafe facade, so the activation policy and service registration are established together. @@ -133,4 +144,44 @@ public CheatEngineClientBuilder EnableUnsafeLuaExecution() serviceProvider.GetRequiredService())); return this; } + + /// Opts this activation into experimental Auto Assembler patches. + /// This builder. + /// + /// was registered by another path than this method. + /// + /// + /// + /// This is the only supported opt-in path: configuration binding cannot enable the capability or register the + /// client, so the activation policy and the registration of are established + /// together. Calling it again keeps the single registration. Without it, nothing is registered and the + /// Client.AutoAssemblerPatches capability reports a Missing policy gate. + /// + /// + /// An Auto Assembler script can allocate target memory, inject code and run Lua in Cheat Engine. Resolve + /// from the activation provider and apply only scripts your plugin owns. + /// + /// + [Experimental(ClientExperimentalDiagnostics.AutoAssemblerPatches, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)] + public CheatEngineClientBuilder EnableAutoAssemblerPatches() + { + if (Services.Any(static descriptor => descriptor.ServiceType == typeof(IAutoAssemblerClient))) + { + if (Services.Any(static descriptor => descriptor.ServiceType == typeof(AutoAssemblerPatchesRegistration))) + { + return this; + } + + throw new InvalidOperationException( + "IAutoAssemblerClient can only be registered through EnableAutoAssemblerPatches()."); + } + + Services.AddSingleton(); + Services.AddSingleton(static serviceProvider => new AutoAssemblerClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + return this; + } } diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs index 011d776..cbd682b 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs @@ -19,26 +19,29 @@ public CheatEngineClientOptions() { } - /// Gets or sets absolute roots from which Client table files may be loaded or saved. + /// Gets the absolute roots from which Client table files may be loaded or saved. /// - /// An empty list denies table-file access by default. Paths are normalized and validated when an activation creates - /// its client scope; relative paths, blank entries, and are rejected. + /// The list is never and is empty by default, which denies table-file access. Configuration + /// binding adds the entries of CheatEngineClient:AllowedTableRoots; a + /// delegate can add, remove, or clear entries. Paths are + /// normalized and validated when an activation creates its client scope; relative paths, blank or + /// entries, and two entries that normalize to the same path are rejected. /// [Required] - public string[]? AllowedTableRoots + public IList AllowedTableRoots { get; - set; - } = Array.Empty(); + } = []; - /// Gets or sets the target-memory budgets captured when an activation creates its client services. + /// Gets the target-memory budgets captured when an activation creates its client services. /// - /// The registration validates and copies these values when it constructs MemoryClient; changing this - /// options object afterwards cannot change the active memory policy. + /// The object is never : configuration binding and + /// delegates set its properties. The registration validates and + /// copies these values when it constructs MemoryClient; changing this options object afterwards cannot + /// change the active memory policy. /// - public MemoryResourceLimits? MemoryResourceLimits + public MemoryResourceLimits MemoryResourceLimits { get; - set; } = new(); } diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs index 32e0484..e36dd8d 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs @@ -1,33 +1,24 @@ +using CheatEngine.Client.Core.Domains; + using Microsoft.Extensions.Options; namespace CheatEngine.Client.Extensions.DependencyInjection; /// Validates the security-sensitive options that cannot be expressed as data annotations. -public sealed class CheatEngineClientOptionsSemanticValidator : IValidateOptions +/// +/// A composition detail: AddCheatEngineClient registers it, and Hosting resolves the options before any Client +/// work, so it runs at every enable. +/// +internal sealed class CheatEngineClientOptionsSemanticValidator : IValidateOptions { - /// Initializes the semantic validator for the Client table-file policy. - public CheatEngineClientOptionsSemanticValidator() - { - } - /// public ValidateOptionsResult Validate(string? name, CheatEngineClientOptions options) { ArgumentNullException.ThrowIfNull(options); - if (options.AllowedTableRoots is null) - { - return ValidateOptionsResult.Fail("AllowedTableRoots must be an empty array or contain absolute paths."); - } - - if (options.MemoryResourceLimits is null) - { - return ValidateOptionsResult.Fail("MemoryResourceLimits must be configured."); - } - try { - _ = options.MemoryResourceLimits.CreateSnapshot(); + _ = MemoryResourceLimitsCopy.CreateValidated(options.MemoryResourceLimits); } catch (ArgumentOutOfRangeException exception) { @@ -35,7 +26,7 @@ public ValidateOptionsResult Validate(string? name, CheatEngineClientOptions opt } HashSet roots = new(StringComparer.OrdinalIgnoreCase); - foreach (string root in options.AllowedTableRoots) + foreach (string? root in options.AllowedTableRoots) { if (string.IsNullOrWhiteSpace(root)) { diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs index 10c2794..9291337 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -5,41 +5,39 @@ using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Domains.Allocations; using CheatEngine.Client.Core.Domains.Assembly; -using CheatEngine.Client.Core.Domains.Dbvm; -using CheatEngine.Client.Core.Domains.Debugger; -using CheatEngine.Client.Core.Domains.Hashing; -using CheatEngine.Client.Core.Domains.Hotkeys; -using CheatEngine.Client.Core.Domains.RemoteExecution; -using CheatEngine.Client.Core.Domains.Speed; -using CheatEngine.Client.Core.Domains.Timers; +using CheatEngine.Client.Core.Domains.ValueScanning; using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace CheatEngine.Client.Extensions.DependencyInjection; /// Registers the high-level Cheat Engine client without building a nested service provider. +/// +/// This package is the composition layer of CheatEngine.Client.Hosting: CheatEnginePluginBuilder calls +/// AddCheatEngineClient once for each activation provider, which Hosting builds, validates and disposes around +/// one Cheat Engine enable epoch. Calling these methods on a collection whose provider Hosting does not own is not +/// supported in 1.0: the Client services would outlive or precede the SDK plugin context they capture. +/// public static class CheatEngineClientServiceCollectionExtensions { - /// Adds the client options, deterministic memory codecs, and explicit Client service registrations. + /// Adds the client options, their validators, logging, and explicit Client service registrations. + /// The service collection of the activation provider. + /// The builder of the Client registrations, to add modules and opt-ins. + /// is . + /// No memory codec is registered: a codec is an application service passed with each codec request. public static CheatEngineClientBuilder AddCheatEngineClient(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); @@ -52,13 +50,20 @@ public static CheatEngineClientBuilder AddCheatEngineClient(this IServiceCollect services.TryAddEnumerable( ServiceDescriptor .Singleton, CheatEngineClientOptionsSemanticValidator>()); - DefaultMemoryCodecs.Add(services); AddCoreServices(services); return new CheatEngineClientBuilder(services); } /// Adds the client and binds its options from the default client section. + /// The service collection of the activation provider. + /// + /// The configuration whose section is bound. + /// + /// The builder of the Client registrations, to add modules and opt-ins. + /// + /// or is . + /// public static CheatEngineClientBuilder AddCheatEngineClient( this IServiceCollection services, IConfiguration configuration) @@ -68,6 +73,12 @@ public static CheatEngineClientBuilder AddCheatEngineClient( } /// Adds the client and binds its options from an explicitly selected configuration section. + /// The service collection of the activation provider. + /// The configuration section bound to the options. + /// The builder of the Client registrations, to add modules and opt-ins. + /// + /// or is . + /// public static CheatEngineClientBuilder AddCheatEngineClient( this IServiceCollection services, IConfigurationSection section) @@ -77,6 +88,12 @@ public static CheatEngineClientBuilder AddCheatEngineClient( } /// Adds the client and applies a programmatic options configuration. + /// The service collection of the activation provider. + /// Configures the options of every activation. + /// The builder of the Client registrations, to add modules and opt-ins. + /// + /// or is . + /// public static CheatEngineClientBuilder AddCheatEngineClient( this IServiceCollection services, Action configure) @@ -89,20 +106,26 @@ private static void AddCoreServices(IServiceCollection services) { // Every descriptor is a direct construction path. The client never scans assemblies, resolves arbitrary types, // or creates a nested provider; Core internals are visible only to this composition assembly. - services.TryAddSingleton(static _ => CoreLifetime.Capture()); + // The Core diagnostics of this activation log through the activation's own logger factory (audit ch.24); Core + // itself references no logging assembly. The sink is a registration of its own, which the lifetime resolves. + services.TryAddSingleton(static serviceProvider => + new LoggerCoreDiagnostics(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + CoreLifetime.Capture(serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => new CheatEngineClientActivationCleanup(serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => { CheatEngineClientOptions options = serviceProvider.GetRequiredService>().Value; - string[] allowedTableRoots = options.AllowedTableRoots - ?? throw new InvalidOperationException( - "AllowedTableRoots must be validated before the Client policy is created."); bool enableUnsafeLuaExecution = serviceProvider .GetService()? .IsEnabled == true; - return new CoreClientPolicy(allowedTableRoots, enableUnsafeLuaExecution); + bool enableAutoAssemblerPatches = serviceProvider + .GetService()? + .IsEnabled == true; + return new CoreClientPolicy(options.AllowedTableRoots, enableUnsafeLuaExecution, + enableAutoAssemblerPatches); }); services.TryAddSingleton(static serviceProvider => @@ -119,12 +142,12 @@ private static void AddCoreServices(IServiceCollection services) serviceProvider.GetRequiredService()); services.TryAddSingleton(); - services.TryAddSingleton(static serviceProvider => - new LocalProcessDiagnostics(serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => new ProcessClient( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), + SdkRuntimeObservationPort.Instance, + SdkProcessSelectionPort.Instance, serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); @@ -133,44 +156,42 @@ private static void AddCoreServices(IServiceCollection services) { CheatEngineClientOptions options = serviceProvider.GetRequiredService>().Value; - MemoryResourceLimits limits = options.MemoryResourceLimits - ?? throw new InvalidOperationException( - "MemoryResourceLimits must be validated before the Client memory service is created."); return new MemoryClient( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), - limits); + options.MemoryResourceLimits); }); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); - services.TryAddSingleton(static serviceProvider => - serviceProvider.GetRequiredService()); services.TryAddSingleton(static serviceProvider => new PatternScanner(serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddSingleton(static serviceProvider => + new ValueScanner(serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => - new UnavailableValueScanner(serviceProvider.GetRequiredService())); + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new AllocationClient(serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); services.TryAddSingleton(static serviceProvider => - new UnavailableAllocationClient(serviceProvider.GetRequiredService())); + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + { + CheatEngineClientOptions options = + serviceProvider.GetRequiredService>().Value; + return new AssemblyClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + options.MemoryResourceLimits); + }); services.TryAddSingleton(static serviceProvider => - new UnavailableAssemblyClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableRemoteExecutionClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableDebuggerClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableHotkeyClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableTimerClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableSpeedClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableHashingClient(serviceProvider.GetRequiredService())); - services.TryAddSingleton(static serviceProvider => - new UnavailableDbvmClient(serviceProvider.GetRequiredService())); + serviceProvider.GetRequiredService()); services.TryAddSingleton(static serviceProvider => new InspectionClient( @@ -208,13 +229,6 @@ private static void AddCoreServices(IServiceCollection services) ILuaClient lua = serviceProvider.GetRequiredService(); IAllocationClient allocations = serviceProvider.GetRequiredService(); IAssemblyClient assembly = serviceProvider.GetRequiredService(); - IRemoteExecutionClient remoteExecution = serviceProvider.GetRequiredService(); - IDebuggerClient debugger = serviceProvider.GetRequiredService(); - IHotkeyClient hotkeys = serviceProvider.GetRequiredService(); - ITimerClient timers = serviceProvider.GetRequiredService(); - ISpeedClient speed = serviceProvider.GetRequiredService(); - IHashingClient hashing = serviceProvider.GetRequiredService(); - IDbvmClient dbvm = serviceProvider.GetRequiredService(); return new CheatEngineClient( lifetime, @@ -228,14 +242,7 @@ private static void AddCoreServices(IServiceCollection services) tables, lua, allocations, - assembly, - remoteExecution, - debugger, - hotkeys, - timers, - speed, - hashing, - dbvm)); + assembly)); }); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/ClientCoreDiagnosticsLog.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/ClientCoreDiagnosticsLog.cs new file mode 100644 index 0000000..ed8ba3f --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/ClientCoreDiagnosticsLog.cs @@ -0,0 +1,96 @@ +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Runtime; + +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Source-generated Core diagnostic events (audit ch.24); event ids 1000–1999 of this assembly. +/// +/// +/// Redaction policy (audit Q46, A24-12 to A24-17): every parameter is an epoch, a count, a width, a duration, a +/// stable operation name, an enum value or a closed reason name. An event never carries an address, a value, a +/// symbol name, a module name, a path, a Lua script or its length unless unsafe Lua ran, an exception message or a +/// failure object. +/// +/// +/// Blocks: 1000–1099 runtime and capability gates, 1100–1199 target selection, 1200–1299 memory, 1300–1399 +/// tables, 1400–1499 inspection, 1500–1599 pattern scans, 1600–1699 Lua, 1700–1799 Client-owned resource cleanup, +/// 1800–1899 Auto Assembler patches. The ids are stable: an id is never reused for another event. +/// +/// +internal static partial class ClientCoreDiagnosticsLog +{ + [LoggerMessage(1000, LogLevel.Debug, + "Runtime snapshot of activation {ActivationEpoch}: target {TargetArchitecture}, process pointer " + + "{ProcessPointerBytes} byte(s), configured pointer {ConfiguredPointerBytes} byte(s) (0 = unknown), width " + + "mismatch {PointerSizeMismatch}.")] + internal static partial void RuntimeSnapshotCaptured(ILogger logger, long activationEpoch, + CheatEngineArchitecture targetArchitecture, int processPointerBytes, int configuredPointerBytes, + bool pointerSizeMismatch); + + [LoggerMessage(1001, LogLevel.Debug, + "Capability {Capability} refused {Operation}: the {Gate} gate is {GateState}.")] + internal static partial void CapabilityRefused(ILogger logger, string capability, string operation, + ClientCapabilityEvidenceReasonCode gate, ClientCapabilityEvidenceState gateState); + + [LoggerMessage(1100, LogLevel.Debug, + "Activation {ActivationEpoch} advanced the target selection to epoch {SelectionEpoch} during {Operation}: " + + "{Reason}.")] + internal static partial void TargetSelectionAdvanced(ILogger logger, long activationEpoch, long selectionEpoch, + string operation, string reason); + + [LoggerMessage(1200, LogLevel.Information, + "{Operation} was refused: Cheat Engine's configured pointer size ({ConfiguredPointerBytes} byte(s)) differs " + + "from the target process width ({ProcessPointerBytes} byte(s)).")] + internal static partial void PointerWidthMismatchRefused(ILogger logger, string operation, int processPointerBytes, + int configuredPointerBytes); + + [LoggerMessage(1201, LogLevel.Debug, + "{Operation} completed {Completed} of {Requested} operation(s); effect state {EffectState}.")] + internal static partial void MemoryBatchCompleted(ILogger logger, string operation, int requested, int completed, + string effectState); + + [LoggerMessage(1300, LogLevel.Debug, + "Activation {ActivationEpoch} advanced the table generation to {TableGeneration} after a trusted table load " + + "reached Cheat Engine.")] + internal static partial void TableGenerationAdvanced(ILogger logger, long activationEpoch, long tableGeneration); + + [LoggerMessage(1301, LogLevel.Debug, + "{Operation} refused a record identifier captured before table generation {TableGeneration}.")] + internal static partial void StaleRecordIdentifierRefused(ILogger logger, string operation, long tableGeneration); + + [LoggerMessage(1302, LogLevel.Debug, + "{Operation} did not apply the requested active state {RequestedState}: {Status}.")] + internal static partial void RecordActivationNotApplied(ILogger logger, string operation, bool requestedState, + string status); + + [LoggerMessage(1400, LogLevel.Debug, "{Operation} rejected the symbol registration: {Reason}.")] + internal static partial void SymbolRegistrationRejected(ILogger logger, string operation, string reason); + + [LoggerMessage(1500, LogLevel.Debug, + "Pattern scan ({Scope}): {HostResultCount} host result(s), {MaterializedCount} materialized, truncated " + + "{Truncated}; host scan {HostScanMilliseconds} ms, copy {CopyMilliseconds} ms.")] + internal static partial void PatternScanCompleted(ILogger logger, PatternScanScope scope, long hostResultCount, + int materializedCount, bool truncated, long hostScanMilliseconds, long copyMilliseconds); + + [LoggerMessage(1600, LogLevel.Debug, + "{Operation} ended with {Outcome} in {ElapsedMilliseconds} ms (unsafe Lua length {ScriptLength}, 0 otherwise).")] + internal static partial void LuaOperationCompleted(ILogger logger, string operation, string outcome, + long elapsedMilliseconds, int scriptLength); + + [LoggerMessage(1700, LogLevel.Warning, "A Client-owned {ComponentType} failed to release with {ExceptionType}.")] + internal static partial void CoreResourceCleanupFailed(ILogger logger, string componentType, string exceptionType); + + [LoggerMessage(1701, LogLevel.Debug, "{Operation} ended with {ReleaseKind} (host effect {HostEffect}).")] + internal static partial void LeaseReleased(ILogger logger, string operation, LeaseReleaseKind releaseKind, + CheatEngineHostEffect hostEffect); + + [LoggerMessage(1800, LogLevel.Warning, + "{Operation} applied an Auto Assembler patch while the selected target changed; the patch stays bound to the " + + "target of selection epoch {SelectionEpoch}, and its effect on either process is uncertain.")] + internal static partial void AutoAssemblerPatchAppliedAfterTargetChange(ILogger logger, string operation, + long selectionEpoch); +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs deleted file mode 100644 index ec9fb01..0000000 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Buffers.Binary; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -using CheatEngine.Client.Memory; -using CheatEngine.SDK.Engine.Values; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CheatEngine.Client.Extensions.DependencyInjection; - -internal static class DefaultMemoryCodecs -{ - internal static void Add(IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); - services.TryAdd(ServiceDescriptor.Singleton>(AddressMemoryCodec.Instance)); - } - - private sealed class UnmanagedMemoryCodec : IMemoryCodec - where T : unmanaged - { - internal static UnmanagedMemoryCodec Instance - { - get; - } = new(); - - public bool TryRead(IMemoryReadContext context, Address address, out T value) - { - ArgumentNullException.ThrowIfNull(context); - Span bytes = stackalloc byte[Unsafe.SizeOf()]; - if (context.TryReadBytes(address, bytes)) - { - value = MemoryMarshal.Read(bytes); - return true; - } - - value = default; - return false; - } - - public bool TryWrite(IMemoryWriteContext context, Address address, in T value) - { - ArgumentNullException.ThrowIfNull(context); - ReadOnlySpan values = MemoryMarshal.CreateReadOnlySpan(in value, 1); - return context.TryWriteBytes(address, MemoryMarshal.AsBytes(values)); - } - } - - private sealed class AddressMemoryCodec : IMemoryCodec
- { - internal static AddressMemoryCodec Instance - { - get; - } = new(); - - public bool TryRead(IMemoryReadContext context, Address address, out Address value) - { - ArgumentNullException.ThrowIfNull(context); - if (!IsSupportedPointerSize(context.PointerSize)) - { - value = default; - return false; - } - - Span bytes = stackalloc byte[sizeof(ulong)]; - Span target = bytes[..context.PointerSize]; - if (!context.TryReadBytes(address, target)) - { - value = default; - return false; - } - - value = Address.FromUInt64(context.PointerSize == sizeof(ulong) - ? BinaryPrimitives.ReadUInt64LittleEndian(target) - : BinaryPrimitives.ReadUInt32LittleEndian(target)); - return true; - } - - public bool TryWrite(IMemoryWriteContext context, Address address, in Address value) - { - ArgumentNullException.ThrowIfNull(context); - if (!IsSupportedPointerSize(context.PointerSize) || - (context.PointerSize == sizeof(uint) && value.Value > uint.MaxValue)) - { - return false; - } - - Span bytes = stackalloc byte[sizeof(ulong)]; - Span target = bytes[..context.PointerSize]; - if (context.PointerSize == sizeof(ulong)) - { - BinaryPrimitives.WriteUInt64LittleEndian(target, value.Value); - } - else - { - BinaryPrimitives.WriteUInt32LittleEndian(target, (uint) value.Value); - } - - return context.TryWriteBytes(address, target); - } - - private static bool IsSupportedPointerSize(int pointerSize) - { - return pointerSize is sizeof(uint) or sizeof(ulong); - } - } -} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs index 00bf812..053a1c1 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs @@ -7,6 +7,20 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// internal interface ICheatEngineClientActivationCleanup { + /// + /// Gets whether CheatEngine.SDK detected that Cheat Engine replaced its Lua state outside the plugin's control + /// during this activation (A8). + /// + /// + /// The SDK's own fact, sticky until the next enable: a lock-free read on any thread that never dispatches and + /// never admits Lua work. Once the SDK detected the reset it refuses every Lua admission, the runtime snapshot's + /// included, so this read is the only place the fact can still be observed during cleanup. + /// + public bool ExternalLuaStateResetDetected + { + get; + } + public IDisposable EnterCleanupScope(); public void DrainOwnedResourcesForDisable(); diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/LoggerCoreDiagnostics.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/LoggerCoreDiagnostics.cs new file mode 100644 index 0000000..69e3a0f --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/LoggerCoreDiagnostics.cs @@ -0,0 +1,279 @@ +using System.Collections.Concurrent; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Runtime; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Writes the Core diagnostic events of one activation to Microsoft.Extensions.Logging. +/// +/// +/// Each domain logs under its own category (CheatEngine.Client.Runtime, .Processes, .Memory, +/// .Tables, .Inspection, .Scanning, .Lua, .Lifetime, .Assembly), so +/// collection is chosen with the standard Logging:LogLevel filters; no Client option controls it. +/// +/// +/// A logger or provider that throws, from , from +/// or while a logger is created, is contained here and never reaches a Client operation, a dispatched callback or +/// cleanup (audit A24-22). A capability refusal is logged once per capability and operation for the activation +/// that owns this sink. +/// +/// +internal sealed class LoggerCoreDiagnostics : ICoreDiagnostics +{ + /// The category of runtime snapshot and capability-gate events. + internal const string RuntimeCategory = "CheatEngine.Client.Runtime"; + + /// The category of target-selection events. + internal const string ProcessesCategory = "CheatEngine.Client.Processes"; + + /// The category of memory events. + internal const string MemoryCategory = "CheatEngine.Client.Memory"; + + /// The category of Address List events. + internal const string TablesCategory = "CheatEngine.Client.Tables"; + + /// The category of symbol events. + internal const string InspectionCategory = "CheatEngine.Client.Inspection"; + + /// The category of pattern-scan events. + internal const string ScanningCategory = "CheatEngine.Client.Scanning"; + + /// The category of Lua events. + internal const string LuaCategory = "CheatEngine.Client.Lua"; + + /// The category of Client-owned resource cleanup events. + internal const string LifetimeCategory = "CheatEngine.Client.Lifetime"; + + /// The category of Auto Assembler patch events. + internal const string AssemblyCategory = "CheatEngine.Client.Assembly"; + + private readonly ConcurrentDictionary<(string Capability, string Operation), byte> _refusalsLogged = new(); + private readonly ILogger _assembly; + private readonly ILogger _inspection; + private readonly ILogger _lifetime; + private readonly ILogger _lua; + private readonly ILogger _memory; + private readonly ILogger _processes; + private readonly ILogger _runtime; + private readonly ILogger _scanning; + private readonly ILogger _tables; + + /// Creates the per-domain loggers of one activation. + /// The logger factory of the activation's service provider. + internal LoggerCoreDiagnostics(ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + _runtime = CreateLogger(loggerFactory, RuntimeCategory); + _processes = CreateLogger(loggerFactory, ProcessesCategory); + _memory = CreateLogger(loggerFactory, MemoryCategory); + _tables = CreateLogger(loggerFactory, TablesCategory); + _inspection = CreateLogger(loggerFactory, InspectionCategory); + _scanning = CreateLogger(loggerFactory, ScanningCategory); + _lua = CreateLogger(loggerFactory, LuaCategory); + _lifetime = CreateLogger(loggerFactory, LifetimeCategory); + _assembly = CreateLogger(loggerFactory, AssemblyCategory); + } + + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch) + { + try + { + ClientCoreDiagnosticsLog.RuntimeSnapshotCaptured(_runtime, activationEpoch, targetArchitecture, + processPointerBytes, configuredPointerBytes, pointerSizeMismatch); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState) + { + (string Capability, string Operation) key = (capability, operation); + if (!_refusalsLogged.TryAdd(key, 0)) + { + return; + } + + try + { + ClientCoreDiagnosticsLog.CapabilityRefused(_runtime, capability, operation, gate, gateState); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). The refusal is not + // counted as logged, so a later refusal of the same capability and operation is emitted again. + _refusalsLogged.TryRemove(key, out _); + } + } + + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason) + { + try + { + ClientCoreDiagnosticsLog.TargetSelectionAdvanced(_processes, activationEpoch, selectionEpoch, operation, + reason); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes) + { + try + { + ClientCoreDiagnosticsLog.PointerWidthMismatchRefused(_memory, operation, processPointerBytes, + configuredPointerBytes); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState) + { + try + { + ClientCoreDiagnosticsLog.MemoryBatchCompleted(_memory, operation, requested, completed, effectState); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration) + { + try + { + ClientCoreDiagnosticsLog.TableGenerationAdvanced(_tables, activationEpoch, tableGeneration); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void StaleRecordIdentifierRefused(string operation, long tableGeneration) + { + try + { + ClientCoreDiagnosticsLog.StaleRecordIdentifierRefused(_tables, operation, tableGeneration); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void RecordActivationNotApplied(string operation, bool requestedState, string status) + { + try + { + ClientCoreDiagnosticsLog.RecordActivationNotApplied(_tables, operation, requestedState, status); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void SymbolRegistrationRejected(string operation, string reason) + { + try + { + ClientCoreDiagnosticsLog.SymbolRegistrationRejected(_inspection, operation, reason); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, + bool truncated, long hostScanMilliseconds, long copyMilliseconds) + { + try + { + ClientCoreDiagnosticsLog.PatternScanCompleted(_scanning, scope, hostResultCount, materializedCount, + truncated, hostScanMilliseconds, copyMilliseconds); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength) + { + try + { + ClientCoreDiagnosticsLog.LuaOperationCompleted(_lua, operation, outcome, elapsedMilliseconds, scriptLength); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + public void CoreResourceCleanupFailed(string componentType, string exceptionType) + { + try + { + ClientCoreDiagnosticsLog.CoreResourceCleanupFailed(_lifetime, componentType, exceptionType); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes the cleanup result (A24-22). + } + } + + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + try + { + ClientCoreDiagnosticsLog.LeaseReleased(_lifetime, operation, kind, hostEffect); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes the release outcome (A24-22). + } + } + + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch) + { + try + { + ClientCoreDiagnosticsLog.AutoAssemblerPatchAppliedAfterTargetChange(_assembly, operation, selectionEpoch); + } + catch (Exception) + { + // Deliberately ignored: a logging provider fault never changes a Client result (A24-22). + } + } + + /// Creates a category logger; a provider that fails here leaves that category silent instead of failing enable. + private static ILogger CreateLogger(ILoggerFactory loggerFactory, string category) + { + try + { + return loggerFactory.CreateLogger(category); + } + catch (Exception) + { + return NullLogger.Instance; + } + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs index d443bb7..e9a2a48 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs @@ -3,7 +3,7 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; -/// Bridges one descriptor-backed Lua module into the activation lifecycle. +/// Bridges one Lua module into the activation lifecycle. /// The explicitly registered Lua module type. /// /// Hosting calls modules in registration order and compensates them in reverse order. The lease is retained here, @@ -11,7 +11,7 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// operation to application code. /// internal sealed class LuaModuleLifecycle(ILuaClient lua, TModule module) : ICheatEngineClientModule - where TModule : class, IDescribedLuaModule + where TModule : class, ILuaModule { private readonly ILuaClient _lua = lua ?? throw new ArgumentNullException(nameof(lua)); private readonly TModule _module = module ?? throw new ArgumentNullException(nameof(module)); diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt index 01efd9a..7dc5c58 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt @@ -1,25 +1 @@ #nullable enable -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddMemoryCodec() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddModule() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.BindConfiguration(Microsoft.Extensions.Configuration.IConfiguration! configuration) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.BindConfiguration(Microsoft.Extensions.Configuration.IConfigurationSection! section) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Configure(System.Action! configure) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.EnableUnsafeLuaExecution() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.get -> string![]? -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.set -> void -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.CheatEngineClientOptions() -> void -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator.CheatEngineClientOptionsSemanticValidator() -> void -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator.Validate(string? name, CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions! options) -> Microsoft.Extensions.Options.ValidateOptionsResult! -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions -CheatEngine.Client.Extensions.DependencyInjection.ValidateCheatEngineClientOptions -CheatEngine.Client.Extensions.DependencyInjection.ValidateCheatEngineClientOptions.Validate(string? name, CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions! options) -> Microsoft.Extensions.Options.ValidateOptionsResult! -CheatEngine.Client.Extensions.DependencyInjection.ValidateCheatEngineClientOptions.ValidateCheatEngineClientOptions() -> void -const CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.ConfigurationSectionName = "CheatEngineClient" -> string! -static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfigurationSection! section) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt index 38e93aa..349ba5d 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt @@ -1,4 +1,20 @@ #nullable enable -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.MemoryResourceLimits.get -> CheatEngine.Client.Memory.MemoryResourceLimits? -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.MemoryResourceLimits.set -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddLuaModule() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddModule() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.BindConfiguration(Microsoft.Extensions.Configuration.IConfiguration! configuration) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.BindConfiguration(Microsoft.Extensions.Configuration.IConfigurationSection! section) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Configure(System.Action! configure) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.EnableUnsafeLuaExecution() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.get -> System.Collections.Generic.IList! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.CheatEngineClientOptions() -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.MemoryResourceLimits.get -> CheatEngine.Client.Memory.MemoryResourceLimits! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions +[CECLIENT5004]CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.EnableAutoAssemblerPatches() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +const CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.ConfigurationSectionName = "CheatEngineClient" -> string! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfigurationSection! section) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md index b836bbb..26f6222 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md @@ -8,9 +8,26 @@ Explicit, AOT-aware dependency-injection composition for the high-level Cheat En implementations. It registers the public domain services—`ICheatEngineClient`, memory, process, scanning, inspection, table, Lua, runtime, and dispatch services—without making consumers reference implementation namespaces. -It depends on `Microsoft.Extensions.DependencyInjection`, `Microsoft.Extensions.Options`, and -`Microsoft.Extensions.Configuration`. The package enables the .NET configuration-binding generator and uses generated -options validation for `CheatEngineClientOptions`; it does not require the Generic Host. +It is the composition layer of `CheatEngine.Client.Hosting`: `CheatEnginePluginBuilder` calls `AddCheatEngineClient` +for each activation provider, which Hosting builds, validates, and disposes around one Cheat Engine enable epoch, and +hands the returned builder to the plugin as `builder.Client`. Composing the Client in a provider that Hosting does not +own is not supported in 1.0: the Client services capture the SDK plugin context of one enable and must not outlive or +precede it. + +It builds on Microsoft.Extensions dependency injection, options, configuration and logging (the packages are listed +under "Dependencies" below). The package enables the .NET configuration-binding generator and uses generated options +validation for `CheatEngineClientOptions`; it does not require the Generic Host. + +## Installation + +A plugin references [`CheatEngine.Client`](https://www.nuget.org/packages/CheatEngine.Client), which brings this +package at exactly its own version through Hosting, and uses it as `builder.Client` in +`CheatEngineClientPlugin.Configure`. The +[CheatEngine.Client README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/src/CheatEngine.Client/README.md) +gives the plugin project, the requirements (`net10.0`, C# 14, a .NET SDK 10.0.401 or later, Cheat Engine 7.7.0.10621 +x64, a direct `CheatEngine.SDK` reference in `[2.0.0, 3.0.0)`) and a minimal plugin. Do not reference this package on +its own: composing the Client outside Hosting is not supported in 1.0, and every Client package takes the same version +(the seven packages ship in lockstep). ## Why this project exists @@ -23,17 +40,22 @@ only supported place to wire Core implementation types into the functional publi ## How it helps CheatEngine.Client -`AddCheatEngineClient` adds direct service registrations, the built-in deterministic memory codecs, options services, -and the Client facade. It returns a `CheatEngineClientBuilder`; the builder only adds registrations and never builds a +`AddCheatEngineClient` adds direct service registrations, logging, options services and their validators, and the +Client facade. It returns a `CheatEngineClientBuilder`; the builder only adds registrations and never builds a provider. Configuration is always opt-in. `BindConfiguration(IConfiguration)` reads the `CheatEngineClient` section, while `BindConfiguration(IConfigurationSection)` lets a plugin choose a different explicit section. The package never searches for, loads, or watches `appsettings.json` on its own. -`CheatEngineClientOptions` controls table-file policy: +`CheatEngineClientOptions` controls table-file policy and memory budgets. Both properties are read-only and never +`null`; configuration binding and `Configure` delegates fill them: -- `AllowedTableRoots` is empty by default, which denies table-file load/save access. +- `AllowedTableRoots` (`IList`) is empty by default, which denies table-file load/save access. +- `MemoryResourceLimits` holds the memory budgets described below. + +The options validators are internal: `AddCheatEngineClient` registers them, and Hosting resolves the options before any +Client work, so an invalid value fails the enable. AOB materialization and value-scan pages require their callers to provide an explicit bound. Unsafe Lua is deliberately not an appsettings option: it can only be enabled with the explicit builder opt-in below. @@ -41,37 +63,117 @@ not an appsettings option: it can only be enabled with the explicit builder opt- The builder also provides explicit extension points: - `AddModule()` preserves module registration order and creates modules in the activation scope; Hosting - enables - modules in that order and disables them in reverse order. -- `AddMemoryCodec()` adds a singleton deterministic codec without reflective structure marshalling. + enables modules in that order and disables them in reverse order. `AddLuaModule()` registers a Lua module, + such as one generated from `[CheatEngineLuaModule]`, whose exports the activation registers and releases. - `EnableUnsafeLuaExecution()` registers the unsafe Lua facade only for the current activation policy. It never exposes an SDK `LuaState`. The opt-in satisfies only the policy evidence gate; it does not prove package support, host globals, or live qualification. +- `EnableAutoAssemblerPatches()` (experimental, `CECLIENT5004`) registers `IAutoAssemblerClient` the same way and + satisfies the policy gate of `Client.AutoAssemblerPatches`. Configuration cannot enable it, calling it twice keeps one + registration, and an `IAutoAssemblerClient` registered by another path makes it throw. Resolve the client from the + activation provider, for example in a module constructor. + +The Client never registers or resolves a memory codec implicitly. The built-in primitives (8- to 64-bit integers, +`float`, `double`, and `Address`) need none. A codec for any other type is an ordinary application service: register it +in `Configure` with `Services.AddSingleton, TCodec>()`, receive it by constructor injection, and pass it +with each codec request (`MemoryReadRequest`, `MemoryWriteRequest`): ```csharp -using CheatEngine.Client.Extensions.DependencyInjection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +using System.Buffers.Binary; + +using CheatEngine.Client; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Annotations.Plugin; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; -var services = new ServiceCollection(); -IConfiguration configuration = new ConfigurationBuilder().Build(); +using Microsoft.Extensions.DependencyInjection; -services.AddCheatEngineClient(configuration) - .AddMemoryCodec(); +namespace MyPlugin; + +[CheatEnginePlugin("My Plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + builder.Services.AddSingleton, PositionCodec>(); + builder.Client.AddModule(); + } +} + +public readonly record struct Position(float X, float Y); + +public sealed class PositionCodec : IMemoryCodec +{ + public bool TryRead(IMemoryReadContext context, Address address, out Position value, out CheatEngineFailure failure) + { + Span bytes = stackalloc byte[8]; + if (!context.TryReadBytes(address, bytes, out failure)) + { + value = default; + return false; + } + + value = new Position( + BinaryPrimitives.ReadSingleLittleEndian(bytes), + BinaryPrimitives.ReadSingleLittleEndian(bytes[4..])); + return true; + } + + public bool TryWrite( + IMemoryWriteContext context, Address address, in Position value, out CheatEngineFailure failure) + { + Span bytes = stackalloc byte[8]; + BinaryPrimitives.WriteSingleLittleEndian(bytes, value.X); + BinaryPrimitives.WriteSingleLittleEndian(bytes[4..], value.Y); + return context.TryWriteBytes(address, bytes, out failure); + } +} + +public sealed class PositionModule(IMemoryCodec codec) : ICheatEngineClientModule +{ + public void OnEnabled(ICheatEngineClient client) + { + // An illustrative pointer chain. A throwing form fails the enable when it does not resolve: use the Try forms + // when the target may not be ready. + Address root = client.Inspection.ResolveAddress( + new SymbolExpression("game.exe+1A2B30"), AddressResolutionMode.Default); + Address player = client.Memory.At(root).Follow([0x10, 0x28]).Resolve(); + + Position position = client.Memory.Read(new MemoryReadRequest(player, codec)); + client.Memory.Write(new MemoryWriteRequest(player, position with { Y = 0 }, codec)); + } + + public void OnDisabling(ICheatEngineClient client) + { + } +} ``` -For an SDK-loaded plugin, use `CheatEngine.Client.Hosting` instead of manually building this collection. The hosting -package creates one validating provider for each enable epoch and resolves `IOptions` -immediately, so generated and semantic validation run before Client work starts. +A codec reads and writes through its context, never through Cheat Engine directly: the context charges every access +against the memory budgets below, and a codec reports an expected failure by returning `false` with the context's +failure, or with the `default` failure to let the Client classify it. + +The hosting package creates one validating provider for each enable epoch and resolves +`IOptions` immediately, so generated and semantic validation run before Client work starts. ## Provider and scope contract `AddCheatEngineClient` configures one provider; it does not define a persistent application root. The supported plugin -path builds a **fresh provider per enable epoch**, then opens one activation scope. The Core Client graph, options, and -deterministic codecs are intentionally provider-local singleton registrations: that is safe because the provider itself -is discarded at disable. Modules are scoped so they can consume scoped application services, but their scoped lifetime +path builds a **fresh provider per enable epoch**, then opens one activation scope. The Core Client graph and options +are intentionally provider-local singleton registrations: that is safe because the provider itself is discarded at +disable. Modules are scoped so they can consume scoped application services, but their scoped lifetime does not make a second scope in the same provider a fresh Client activation. +A fresh provider per enable does not isolate CheatEngine.SDK static state (`PluginHost` and the current plugin +context) or Cheat Engine's Lua globals: every plugin that loads the same SDK assemblies into the Cheat Engine process +shares them, whatever its container. Coexistence of two plugins that share or do not share the SDK assemblies is +the live scenario Q09 (two plugins in one Cheat Engine process), for which no Client receipt exists yet. + Two scopes made from one external provider are ordinary sibling DI scopes. Their scoped services and modules differ, while provider singletons, options, and Client services remain shared until the provider is disposed. Do not reuse such a provider across Cheat Engine enable epochs; Client does not offer a persistent-root hosting mode or an activation @@ -81,3 +183,42 @@ The container disposes services it creates at their scope/provider boundary. Do in a module or plugin callback, and do not register one disposable object through multiple forwarding aliases. Give the disposable one owning descriptor; expose an additional non-disposable facade when an application needs an alias. The host itself owns only its `ConfigurationManager`, which it releases after the scope and provider. + +## Diagnostics + +`AddCheatEngineClient` calls `AddLogging()` and gives the activation's Core lifetime a diagnostics sink over the +provider's `ILoggerFactory`. Core emits bounded events (event ids 1000–1800; the +[`CheatEngine.Client.Core` README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Core/README.md#diagnostics-events) +lists them) under one category per domain: `CheatEngine.Client.Runtime`, `.Processes`, `.Memory`, `.Tables`, +`.Inspection`, `.Scanning`, `.Lua`, `.Lifetime`, and `.Assembly`. Select them with the standard `Logging:LogLevel` +filters, for example +`"CheatEngine.Client.Memory": "Debug"`; no Client option controls collection. A capability refusal is logged once per +capability and operation per activation. The events carry epochs, counts, widths, durations, and closed names only, +never addresses, values, symbol names, paths, or Lua text, and a logging provider that throws is contained: it cannot +change a Client result or cleanup. + +## Memory resource limits + +`CheatEngineClientOptions.MemoryResourceLimits` (configuration keys such as +`CheatEngineClient:MemoryResourceLimits:MaximumReadBytes`) is validated with the options and copied when the memory +client is created, so a later change does not affect the running activation. It uses the Client limit vocabulary. +`MaximumReadBytes`, `MaximumWriteBytes`, and `MaximumStringBytes` set the **maximum block size** of one operation. +`MaximumBatchOperationCount`, capped by `MemoryBatchLimits.MaximumOperationCount` (1024), sets the **request count per +batch**, and `MaximumBatchPayloadBytes` bounds that count multiplied by the element size. Together these budgets set the +**maximum scratch allocation**: the largest managed buffer allocated for one operation. The target process gets no +allocation. The **partial-effect state** of a batch write is not configurable: `MemoryBatchWriteEffectState` reports +it, because a failed batch keeps its completed prefix and is never rolled back. + +## Dependencies + +The package depends on `CheatEngine.Client.Abstractions` and `CheatEngine.Client.Core` at exactly its own version (the +Client packages ship in lockstep), and on `Microsoft.Extensions.Configuration.Abstractions`, +`Microsoft.Extensions.DependencyInjection`, `Microsoft.Extensions.Logging`, +`Microsoft.Extensions.Options.ConfigurationExtensions` and `Microsoft.Extensions.Options.DataAnnotations`. Each +Microsoft.Extensions dependency is a minimum version: the latest 10.0.x patch reviewed for this release, which the +Client moves only through reviewed dependency updates and never lowers. NuGet resolves the lowest version that satisfies +every minimum of the graph; a plugin that needs a later 10.0.x patch references it directly. + +This assembly grants `InternalsVisibleTo` to `CheatEngine.Client.Hosting`, which composes it, and to the repository's +test projects. The Client assemblies are not strong-named, so such a grant names an assembly, not a signing key. Its +internal members are not a contract and change in any release: use the public API only. diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs index db0656e..2c5f81b 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs @@ -1,4 +1,4 @@ -namespace CheatEngine.Client.Extensions.DependencyInjection; +namespace CheatEngine.Client.Extensions.DependencyInjection; /// Records the builder-only opt-in required to enable unsafe Lua for an activation. internal sealed class UnsafeLuaExecutionRegistration diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs index f46350c..968b5f4 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs @@ -3,7 +3,8 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// Provides generated, trimming-safe validation for . +/// A composition detail: AddCheatEngineClient registers it next to the semantic validator. [OptionsValidator] -public sealed partial class ValidateCheatEngineClientOptions : IValidateOptions +internal sealed partial class ValidateCheatEngineClientOptions : IValidateOptions { } diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json b/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json index 1747fcb..914eade 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json @@ -66,24 +66,17 @@ "resolved": "10.0.12", "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, - "Microsoft.SourceLink.GitHub": { + "Microsoft.Sbom.Targets": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } + "requested": "[4.1.13, )", + "resolved": "4.1.13", + "contentHash": "l9NiCqVmBBY06Lrxv61xWtiLvU1feto6j7QmMsaARBopO+QLTFDFLEIbYe8pYGjk1INJ2eWE/GGjToqQpokYAw==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" }, "Microsoft.Extensions.Configuration": { "type": "Transitive", @@ -108,34 +101,24 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.Extensions.Configuration.Binder": { "type": "CentralTransitive", @@ -164,4 +147,4 @@ } } } -} +} \ No newline at end of file diff --git a/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj b/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj index 12f8df2..9972517 100644 --- a/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj +++ b/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj @@ -2,6 +2,7 @@ CheatEngine.Client + Immutable fluent builders for CheatEngine.Client memory and AOB requests, with bounded terminal operations. diff --git a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs index d2c9fa2..16f22b3 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs @@ -2,7 +2,11 @@ namespace CheatEngine.Client.Memory; -/// Fluent entry points for the scoped target-memory contract. +/// The fluent entry points of the target-memory domain, bound to a scoped . +/// +/// A memory builder always starts from the service that runs its terminal operations, usually client.Memory +/// of an activation-scoped : it is never created unbound and never rebound. +/// public static class CheatEngineMemoryFluentExtensions { /// Starts a fluent, immutable operation against . @@ -12,16 +16,21 @@ public static class CheatEngineMemoryFluentExtensions /// is . public static MemoryAddressBuilder At(this IMemoryClient memory, Address address) { - return Memory.At(memory, address); + ArgumentNullException.ThrowIfNull(memory); + return new MemoryAddressBuilder(address, memory); } /// Starts a bounded homogeneous primitive batch through the scoped target-memory service. - /// The built-in scalar or target-aware pointer type. + /// + /// The built-in scalar or target-aware pointer type, one of the primitives supports. + /// /// The scoped target-memory service used by terminal operations. /// An immutable batch builder bound to . /// is . public static MemoryPrimitiveBatchBuilder Batch(this IMemoryClient memory) + where T : unmanaged { - return Memory.Batch(memory); + ArgumentNullException.ThrowIfNull(memory); + return new MemoryPrimitiveBatchBuilder(memory); } } diff --git a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs deleted file mode 100644 index 935963c..0000000 --- a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Memory; - -/// Starts a fluent, handle-free operation against one target-memory address. -public static class Memory -{ - /// Creates an unbound address builder. - /// The target address to read or write. - /// - /// An immutable address builder that must be bound with Using(memory) - /// before a built-in terminal operation. - /// - public static MemoryAddressBuilder At(Address address) - { - return new MemoryAddressBuilder(address, null); - } - - /// Creates an address builder bound to the supplied memory service. - /// The scoped target-memory service used by terminal operations. - /// The target address to read or write. - /// An immutable address builder. - /// is . - public static MemoryAddressBuilder At(IMemoryClient memory, Address address) - { - ArgumentNullException.ThrowIfNull(memory); - return new MemoryAddressBuilder(address, memory); - } - - /// Creates a fluent terminal for one bounded homogeneous primitive batch. - /// The built-in scalar or target-aware pointer type. - /// The scoped target-memory service used by terminal operations. - /// An immutable batch builder bound to . - /// is . - public static MemoryPrimitiveBatchBuilder Batch(IMemoryClient memory) - { - ArgumentNullException.ThrowIfNull(memory); - return new MemoryPrimitiveBatchBuilder(memory); - } -} diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs index 4468c71..b247731 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs @@ -7,11 +7,29 @@ namespace CheatEngine.Client.Memory; /// An immutable, handle-free builder for one target-memory address. -public readonly record struct MemoryAddressBuilder +/// +/// +/// Start it with , for example +/// client.Memory.At(address): the builder is bound to that memory service and never rebound. It is a plain +/// value that declares no Equals, GetHashCode, ToString or equality operators (only those +/// inherited from ): compare values, not builders. +/// +/// +/// Its only constructor is the implicit parameterless one, which yields the value: +/// that value has no target-memory service, and every terminal operation and throw +/// on it. +/// +/// +/// Each throwing terminal calls the throwing member of the bound memory service, which the Client implements +/// with : the exception type follows +/// , and the matching Try form returns the same failure instead. +/// +/// +public readonly struct MemoryAddressBuilder { private readonly IMemoryClient? _memory; - internal MemoryAddressBuilder(Address address, IMemoryClient? memory) + internal MemoryAddressBuilder(Address address, IMemoryClient memory) { Address = address; _memory = memory; @@ -23,22 +41,31 @@ public Address Address get; } - /// Returns an equivalent builder bound to a scoped target-memory service. - /// The scoped target-memory service used by terminal operations. - /// A new immutable builder. - /// is . - public MemoryAddressBuilder Using(IMemoryClient memory) - { - ArgumentNullException.ThrowIfNull(memory); - return new MemoryAddressBuilder(Address, memory); - } - /// Reads one built-in scalar or pointer type through the bound memory service. /// The built-in scalar or pointer type to read. - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// The value read from . - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// The bound memory service refused or failed the read. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// public T Read(CancellationToken cancellationToken = default) + where T : unmanaged { return RequireMemory().ReadPrimitive(Address, cancellationToken); } @@ -47,11 +74,23 @@ public T Read(CancellationToken cancellationToken = default) /// The built-in scalar or pointer type to read. /// The value read when the method returns . /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when a value was read. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryRead([MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { return RequireMemory().TryReadPrimitive(Address, out value, out failure, cancellationToken); } @@ -59,9 +98,28 @@ public bool TryRead([MaybeNullWhen(false)] out T value, out CheatEngineFailur /// Writes one built-in scalar or pointer type through the bound memory service. /// The built-in scalar or pointer type to write. /// The value to write to . - /// Cancels before the operation reaches Cheat Engine. - /// No memory service has been bound to this builder. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// The bound memory service refused or failed the write. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// public void Write(T value, CancellationToken cancellationToken = default) + where T : unmanaged { RequireMemory().WritePrimitive(Address, value, cancellationToken); } @@ -70,20 +128,51 @@ public void Write(T value, CancellationToken cancellationToken = default) /// The built-in scalar or pointer type to write. /// The value to write to . /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when Cheat Engine accepted the write. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryWrite(T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { return RequireMemory().TryWritePrimitive(Address, value, out failure, cancellationToken); } /// Copies an exact, positive number of target bytes from this address. /// The exact positive number of bytes to copy. - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// An immutable, caller-owned byte snapshot. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is zero or negative. + /// + /// The bound memory service refused or failed the copy. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the copy failed with + /// . + /// public ImmutableArray ReadBytes(int length, CancellationToken cancellationToken = default) { return RequireMemory().ReadBytes(new MemoryBytesReadRequest(Address, length), cancellationToken); @@ -93,9 +182,21 @@ public ImmutableArray ReadBytes(int length, CancellationToken cancellation /// The exact positive number of bytes to copy. /// The immutable byte snapshot when the method returns . /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when the bytes were copied. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is zero or negative. + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryReadBytes(int length, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -105,8 +206,27 @@ public bool TryReadBytes(int length, out ImmutableArray bytes, out CheatEn /// Copies the supplied non-empty bytes to this address. /// The caller-owned bytes copied into an immutable request before dispatch. - /// Cancels before the operation reaches Cheat Engine. - /// No memory service has been bound to this builder. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// + /// The bound memory service refused or failed the write. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// public void WriteBytes(ReadOnlySpan bytes, CancellationToken cancellationToken = default) { RequireMemory().WriteBytes(new MemoryBytesWriteRequest(Address, bytes), cancellationToken); @@ -115,9 +235,21 @@ public void WriteBytes(ReadOnlySpan bytes, CancellationToken cancellationT /// Tries to copy the supplied non-empty bytes to this address. /// The caller-owned bytes copied into an immutable request before dispatch. /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when Cheat Engine accepted the write. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryWriteBytes(ReadOnlySpan bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -125,85 +257,109 @@ public bool TryWriteBytes(ReadOnlySpan bytes, out CheatEngineFailure failu cancellationToken); } - /// Reads a bounded UTF-8 string from this address. - /// The positive maximum length passed to Cheat Engine. - /// Cancels before the operation reaches Cheat Engine. - /// The copied UTF-8 text. - /// No memory service has been bound to this builder. - public string ReadUtf8(int maximumLength, CancellationToken cancellationToken = default) - { - return ReadString(maximumLength, MemoryStringEncoding.Utf8, cancellationToken); - } - - /// Reads a bounded UTF-16 string from this address. - /// The positive maximum length passed to Cheat Engine. - /// Cancels before the operation reaches Cheat Engine. - /// The copied UTF-16 text. - /// No memory service has been bound to this builder. - public string ReadUtf16(int maximumLength, CancellationToken cancellationToken = default) - { - return ReadString(maximumLength, MemoryStringEncoding.Utf16, cancellationToken); - } - /// Reads a bounded string with an explicit target encoding from this address. - /// The positive maximum length passed to Cheat Engine. + /// + /// The positive value passed unchanged as Cheat Engine's readString maxlength argument, a host-side + /// bound whose unit is not documented (see ). + /// /// The UTF-8 or UTF-16 target representation. - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// The copied target text. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// is zero or negative, or is not defined. + /// + /// + /// The bound memory service refused or failed the read. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// public string ReadString(int maximumLength, MemoryStringEncoding encoding, CancellationToken cancellationToken = default) { - return RequireMemory().ReadString(MemoryStringReadRequest.Create(Address, maximumLength, encoding), + return RequireMemory().ReadString(new MemoryStringReadRequest(Address, maximumLength, encoding), cancellationToken); } /// Tries to read a bounded string with an explicit target encoding from this address. - /// The positive maximum length passed to Cheat Engine. + /// + /// The positive value passed unchanged as Cheat Engine's readString maxlength argument, a host-side + /// bound whose unit is not documented (see ). + /// /// The UTF-8 or UTF-16 target representation. /// The copied target text when the method returns . /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when the text was copied. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// is zero or negative, or is not defined. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryReadString(int maximumLength, MemoryStringEncoding encoding, [NotNullWhen(true)] out string? value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return RequireMemory().TryReadString(MemoryStringReadRequest.Create(Address, maximumLength, encoding), + return RequireMemory().TryReadString(new MemoryStringReadRequest(Address, maximumLength, encoding), out value, out failure, cancellationToken); } - /// Writes UTF-8 text whose encoded length is bounded explicitly at this address. - /// The managed text to copy. - /// The positive maximum number of UTF-8 bytes accepted. - /// Cancels before the operation reaches Cheat Engine. - /// No memory service has been bound to this builder. - public void WriteUtf8(string value, int maximumLength, CancellationToken cancellationToken = default) - { - WriteString(value, maximumLength, MemoryStringEncoding.Utf8, cancellationToken); - } - - /// Writes UTF-16 text whose code-unit length is bounded explicitly at this address. - /// The managed text to copy. - /// The positive maximum number of UTF-16 code units accepted. - /// Cancels before the operation reaches Cheat Engine. - /// No memory service has been bound to this builder. - public void WriteUtf16(string value, int maximumLength, CancellationToken cancellationToken = default) - { - WriteString(value, maximumLength, MemoryStringEncoding.Utf16, cancellationToken); - } - /// Writes text with an explicit target encoding and maximum encoded length. /// The managed text to copy. /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. /// The UTF-8 or UTF-16 target representation. - /// Cancels before the operation reaches Cheat Engine. - /// No memory service has been bound to this builder. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// + /// + /// This builder is the value, which has no target-memory service. + /// + /// is . + /// + /// is zero or negative, or is not defined. + /// + /// The encoded text exceeds . + /// + /// The bound memory service refused or failed the write. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// public void WriteString(string value, int maximumLength, MemoryStringEncoding encoding, CancellationToken cancellationToken = default) { - RequireMemory().WriteString(MemoryStringWriteRequest.CreateBounded(Address, value, maximumLength, encoding), + RequireMemory().WriteString(new MemoryStringWriteRequest(Address, value, maximumLength, encoding), cancellationToken); } @@ -212,51 +368,75 @@ public void WriteString(string value, int maximumLength, MemoryStringEncoding en /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. /// The UTF-8 or UTF-16 target representation. /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when Cheat Engine accepted the write. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is . + /// + /// is zero or negative, or is not defined. + /// + /// The encoded text exceeds . + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryWriteString(string value, int maximumLength, MemoryStringEncoding encoding, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { return RequireMemory().TryWriteString( - MemoryStringWriteRequest.CreateBounded(Address, value, maximumLength, encoding), + new MemoryStringWriteRequest(Address, value, maximumLength, encoding), out failure, cancellationToken); } /// Starts a finite, target-aware pointer chain from this address. /// The non-empty sequence of at most 64 offsets applied after each dereference. - /// An immutable chain builder that remains bound to this builder's memory service. + /// An immutable chain builder bound to this builder's memory service. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// holds more than 64 offsets. public MemoryPointerChainBuilder Follow(ReadOnlySpan offsets) { - return new MemoryPointerChainBuilder(new PointerChainRequest(Address, offsets), _memory); + IMemoryClient memory = RequireMemory(); + return new MemoryPointerChainBuilder(new PointerChainRequest(Address, offsets), memory); } /// Reads one typed value through the service bound to this builder. /// The managed value type represented by . /// The deterministic codec that maps to Cheat Engine memory. - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// The managed value returned by Cheat Engine. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// /// is . - public T ReadWith(IMemoryCodec codec, CancellationToken cancellationToken = default) - { - return ReadWith(RequireMemory(), codec, cancellationToken); - } - - /// Reads one typed value through an explicit target-memory service. - /// The managed value type represented by . - /// The scoped target-memory service used for this operation. - /// The deterministic codec that maps to Cheat Engine memory. - /// Cancels before the operation reaches Cheat Engine. - /// The managed value returned by Cheat Engine. - /// - /// or is - /// . + /// + /// The bound memory service refused or failed the read. /// - public T ReadWith(IMemoryClient memory, IMemoryCodec codec, - CancellationToken cancellationToken = default) + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping (outside a deactivation callback, or inside one when the codec uses its + /// context), or the read failed with . + /// + public T ReadWith(IMemoryCodec codec, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(memory); + IMemoryClient memory = RequireMemory(); ArgumentNullException.ThrowIfNull(codec); return memory.Read(new MemoryReadRequest(Address, codec), cancellationToken); } @@ -266,33 +446,27 @@ public T ReadWith(IMemoryClient memory, IMemoryCodec codec, /// The deterministic codec that maps to Cheat Engine memory. /// The managed value when the method returns . /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when a value was read. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// /// is . + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping (outside a deactivation callback, or inside one when the codec uses its + /// context). + /// public bool TryReadWith(IMemoryCodec codec, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return TryReadWith(RequireMemory(), codec, out value, out failure, cancellationToken); - } - - /// Tries to read one typed value through an explicit target-memory service. - /// The managed value type represented by . - /// The scoped target-memory service used for this operation. - /// The deterministic codec that maps to Cheat Engine memory. - /// The managed value when the method returns . - /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. - /// when a value was read. - /// - /// or is - /// . - /// - public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNullWhen(false)] out T value, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(memory); + IMemoryClient memory = RequireMemory(); ArgumentNullException.ThrowIfNull(codec); return memory.TryRead(new MemoryReadRequest(Address, codec), out value, out failure, cancellationToken); } @@ -301,30 +475,30 @@ public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNu /// The managed value type represented by . /// The managed value to write. /// The deterministic codec that maps to Cheat Engine memory. - /// Cancels before the operation reaches Cheat Engine. - /// Nothing when Cheat Engine accepted the write. - /// No memory service has been bound to this builder. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// + /// + /// This builder is the value, which has no target-memory service. + /// /// is . - public void WriteWith(T value, IMemoryCodec codec, CancellationToken cancellationToken = default) - { - WriteWith(RequireMemory(), value, codec, cancellationToken); - } - - /// Writes one typed value through an explicit target-memory service. - /// The managed value type represented by . - /// The scoped target-memory service used for this operation. - /// The managed value to write. - /// The deterministic codec that maps to Cheat Engine memory. - /// Cancels before the operation reaches Cheat Engine. - /// Nothing when Cheat Engine accepted the write. - /// - /// or is - /// . + /// + /// The bound memory service refused or failed the write. /// - public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec, - CancellationToken cancellationToken = default) + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping (outside a deactivation callback, or inside one when the codec uses its + /// context), or the write failed with . + /// + public void WriteWith(T value, IMemoryCodec codec, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(memory); + IMemoryClient memory = RequireMemory(); ArgumentNullException.ThrowIfNull(codec); memory.Write(new MemoryWriteRequest(Address, value, codec), cancellationToken); } @@ -334,33 +508,26 @@ public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec, /// The managed value to write. /// The deterministic codec that maps to Cheat Engine memory. /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when Cheat Engine accepted the write. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// /// is . - public bool TryWriteWith(T value, IMemoryCodec codec, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - return TryWriteWith(RequireMemory(), value, codec, out failure, cancellationToken); - } - - /// Tries to write one typed value through an explicit target-memory service. - /// The managed value type represented by . - /// The scoped target-memory service used for this operation. - /// The managed value to write. - /// The deterministic codec that maps to Cheat Engine memory. - /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. - /// when Cheat Engine accepted the write. - /// - /// or is - /// . + /// + /// The Client activation that owns the memory service has ended. /// - public bool TryWriteWith(IMemoryClient memory, T value, IMemoryCodec codec, - out CheatEngineFailure failure, + /// + /// The Client activation is stopping (outside a deactivation callback, or inside one when the codec uses its + /// context). + /// + public bool TryWriteWith(T value, IMemoryCodec codec, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(memory); + IMemoryClient memory = RequireMemory(); ArgumentNullException.ThrowIfNull(codec); return memory.TryWrite(new MemoryWriteRequest(Address, value, codec), out failure, cancellationToken); } @@ -368,7 +535,7 @@ public bool TryWriteWith(IMemoryClient memory, T value, IMemoryCodec codec private IMemoryClient RequireMemory() { return _memory ?? throw new InvalidOperationException( - "This memory builder has no bound target-memory service. Use Memory.At(memory, address), " + - "memory.At(address), or bind the builder with Using(memory) before a terminal operation."); + "This memory builder is a default value without a target-memory service. Start it with " + + "memory.At(address), for example client.Memory.At(address)."); } } diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs index 3b84437..29cdb3f 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs @@ -4,11 +4,29 @@ namespace CheatEngine.Client.Memory; /// An immutable, handle-free fluent operation for one finite target-aware pointer chain. +/// +/// +/// Start it with , for example +/// client.Memory.At(address).Follow(offsets): the chain is bound to that memory service and never rebound. +/// It is a plain value that declares no Equals, GetHashCode, ToString or equality operators +/// (only those inherited from ): compare values, not builders. +/// +/// +/// Its only constructor is the implicit parameterless one, which yields the value: +/// that value has no target-memory service, and and throw +/// on it. +/// +/// +/// calls the throwing member of the bound memory service, which the Client implements with +/// : the exception type follows +/// , and returns the same failure instead. +/// +/// public readonly struct MemoryPointerChainBuilder { private readonly IMemoryClient? _memory; - internal MemoryPointerChainBuilder(PointerChainRequest request, IMemoryClient? memory) + internal MemoryPointerChainBuilder(PointerChainRequest request, IMemoryClient memory) { Request = request; _memory = memory; @@ -20,20 +38,28 @@ public PointerChainRequest Request get; } - /// Returns an equivalent pointer-chain builder bound to a scoped target-memory service. - /// The scoped target-memory service used by terminal operations. - /// A new immutable builder. - /// is . - public MemoryPointerChainBuilder Using(IMemoryClient memory) - { - ArgumentNullException.ThrowIfNull(memory); - return new MemoryPointerChainBuilder(Request, memory); - } - /// Resolves every pointer dereference and offset in the chain. - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// The copied final target address. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// The bound memory service refused or failed the resolution. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the resolution failed with + /// . + /// public Address Resolve(CancellationToken cancellationToken = default) { return RequireMemory().ResolvePointerChain(Request, cancellationToken); @@ -42,9 +68,20 @@ public Address Resolve(CancellationToken cancellationToken = default) /// Tries to resolve every pointer dereference and offset in the chain. /// The copied final target address when the method returns . /// The classified operation failure when the method returns . - /// Cancels before the operation reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when the chain was resolved. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryResolve(out Address address, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -54,7 +91,7 @@ public bool TryResolve(out Address address, out CheatEngineFailure failure, private IMemoryClient RequireMemory() { return _memory ?? throw new InvalidOperationException( - "This pointer-chain builder has no bound target-memory service. Use Memory.At(memory, address), " + - "memory.At(address), or bind the chain with Using(memory) before a terminal operation."); + "This pointer-chain builder is a default value without a target-memory service. Start the chain with " + + "memory.At(address).Follow(offsets), for example client.Memory.At(address).Follow(offsets)."); } } diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs index 8c9330f..85291a9 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs @@ -6,21 +6,65 @@ namespace CheatEngine.Client.Memory; /// An immutable, handle-free fluent terminal for one bounded homogeneous scalar batch. -/// The built-in scalar or target-aware pointer type. +/// +/// The built-in scalar or target-aware pointer type, one of the primitives supports. +/// +/// +/// +/// Start it with , for example +/// client.Memory.Batch<int>(): the batch is bound to that memory service and never rebound. It is a +/// plain value that declares no Equals, GetHashCode, ToString or equality operators (only +/// those inherited from ). +/// +/// +/// Its only constructor is the implicit parameterless one, which yields the value: +/// that value has no target-memory service, and every terminal operation throws +/// on it, before it builds or dispatches a request. +/// +/// +/// Each throwing terminal calls the throwing member of the bound memory service, which the Client implements +/// with : the exception type follows +/// , and the matching Try form returns the same failure instead. +/// +/// public readonly struct MemoryPrimitiveBatchBuilder + where T : unmanaged { private readonly IMemoryClient? _memory; internal MemoryPrimitiveBatchBuilder(IMemoryClient memory) { - _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + _memory = memory; } /// Copies one homogeneous scalar from every supplied target address in one dispatch admission. /// The non-empty target addresses to read in result order. - /// Cancels before the batch reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// The immutable scalar snapshot in the same order as . - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// + /// holds more than + /// addresses. + /// + /// + /// The bound memory service refused or failed the read. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the read failed with + /// . + /// public ImmutableArray Read(ReadOnlySpan
addresses, CancellationToken cancellationToken = default) { return RequireMemory().ReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), cancellationToken); @@ -30,9 +74,25 @@ public ImmutableArray Read(ReadOnlySpan
addresses, CancellationToken /// The non-empty target addresses to read in result order. /// The immutable scalar snapshot when the method returns . /// The classified operation failure when the method returns . - /// Cancels before the batch reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when all scalar reads succeeded. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// + /// holds more than + /// addresses. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryRead(ReadOnlySpan
addresses, out ImmutableArray values, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -42,8 +102,30 @@ public bool TryRead(ReadOnlySpan
addresses, out ImmutableArray value /// Writes every copied homogeneous scalar in one dispatch admission. /// The non-empty address/value pairs to write in execution order. - /// Cancels before the batch reaches Cheat Engine. - /// No memory service has been bound to this builder. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// + /// holds more than values. + /// + /// + /// The bound memory service refused or failed the write. + /// + /// + /// was observed before dispatch or between Client-managed steps. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback, or the write failed with + /// . + /// public void Write(ReadOnlySpan> values, CancellationToken cancellationToken = default) { RequireMemory().WritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), cancellationToken); @@ -52,9 +134,24 @@ public void Write(ReadOnlySpan> values, CancellationToken /// Tries to write every copied homogeneous scalar in one dispatch admission. /// The non-empty address/value pairs to write in execution order. /// The classified operation failure when the method returns . - /// Cancels before the batch reaches Cheat Engine. + /// + /// Observed before dispatch and between Client-managed steps; it never interrupts a Cheat Engine call that has + /// already started (see ). + /// /// when all scalar writes succeeded. - /// No memory service has been bound to this builder. + /// + /// This builder is the value, which has no target-memory service. + /// + /// is empty. + /// + /// holds more than values. + /// + /// + /// The Client activation that owns the memory service has ended. + /// + /// + /// The Client activation is stopping, outside a deactivation callback. + /// public bool TryWrite(ReadOnlySpan> values, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -65,7 +162,7 @@ public bool TryWrite(ReadOnlySpan> values, out CheatEngine private IMemoryClient RequireMemory() { return _memory ?? throw new InvalidOperationException( - "This primitive-batch builder has no bound target-memory service. Use Memory.Batch(memory) or " + - "memory.Batch() before a terminal operation."); + "This primitive-batch builder is a default value without a target-memory service. Start it with " + + "memory.Batch(), for example client.Memory.Batch()."); } } diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt index fd41b59..7dc5c58 100644 --- a/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt @@ -1,82 +1 @@ #nullable enable -~override CheatEngine.Client.Memory.MemoryAddressBuilder.Equals(object obj) -> bool -~override CheatEngine.Client.Memory.MemoryAddressBuilder.ToString() -> string -~override CheatEngine.Client.Scanning.AobFirstMatchBuilder.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobFirstMatchBuilder.ToString() -> string -~override CheatEngine.Client.Scanning.AobManyMatchBuilder.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobManyMatchBuilder.ToString() -> string -~override CheatEngine.Client.Scanning.AobScanBuilder.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobScanBuilder.ToString() -> string -~override CheatEngine.Client.Scanning.AobSingleMatchBuilder.Equals(object obj) -> bool -~override CheatEngine.Client.Scanning.AobSingleMatchBuilder.ToString() -> string -CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions -CheatEngine.Client.Memory.Memory -CheatEngine.Client.Memory.MemoryAddressBuilder -CheatEngine.Client.Memory.MemoryAddressBuilder.Address.get -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Memory.MemoryAddressBuilder.Equals(CheatEngine.Client.Memory.MemoryAddressBuilder other) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.MemoryAddressBuilder() -> void -CheatEngine.Client.Memory.MemoryAddressBuilder.Read(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T -CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T -CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T -CheatEngine.Client.Memory.MemoryAddressBuilder.TryRead(out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.TryWrite(T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(CheatEngine.Client.Memory.IMemoryClient! memory, T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryAddressBuilder.Using(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryAddressBuilder -CheatEngine.Client.Memory.MemoryAddressBuilder.Write(T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(CheatEngine.Client.Memory.IMemoryClient! memory, T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Scanning.AobFirstMatchBuilder -CheatEngine.Client.Scanning.AobFirstMatchBuilder.AobFirstMatchBuilder() -> void -CheatEngine.Client.Scanning.AobFirstMatchBuilder.Equals(CheatEngine.Client.Scanning.AobFirstMatchBuilder other) -> bool -CheatEngine.Client.Scanning.AobFirstMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address? -CheatEngine.Client.Scanning.AobFirstMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address? address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.AobManyMatchBuilder -CheatEngine.Client.Scanning.AobManyMatchBuilder.AobManyMatchBuilder() -> void -CheatEngine.Client.Scanning.AobManyMatchBuilder.Equals(CheatEngine.Client.Scanning.AobManyMatchBuilder other) -> bool -CheatEngine.Client.Scanning.AobManyMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult -CheatEngine.Client.Scanning.AobManyMatchBuilder.TryExecute(out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobScanBuilder.AobScanBuilder() -> void -CheatEngine.Client.Scanning.AobScanBuilder.Equals(CheatEngine.Client.Scanning.AobScanBuilder other) -> bool -CheatEngine.Client.Scanning.AobScanBuilder.FirstOrNone() -> CheatEngine.Client.Scanning.AobFirstMatchBuilder -CheatEngine.Client.Scanning.AobScanBuilder.InModule(CheatEngine.SDK.Engine.Inspection.ModuleName module) -> CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobScanBuilder.InModule(string! moduleName) -> CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobScanBuilder.InRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobScanBuilder.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName? -CheatEngine.Client.Scanning.AobScanBuilder.Options.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions -CheatEngine.Client.Scanning.AobScanBuilder.Pattern.get -> CheatEngine.Client.Scanning.AobPattern -CheatEngine.Client.Scanning.AobScanBuilder.Range.get -> CheatEngine.Client.Scanning.AobScanRange? -CheatEngine.Client.Scanning.AobScanBuilder.ReadableExecutable() -> CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobScanBuilder.RequireSingle() -> CheatEngine.Client.Scanning.AobSingleMatchBuilder -CheatEngine.Client.Scanning.AobScanBuilder.Take(int maximumResults) -> CheatEngine.Client.Scanning.AobManyMatchBuilder -CheatEngine.Client.Scanning.AobScanBuilder.WithAlignment(CheatEngine.SDK.Engine.Enums.FastScanMethod method, string? parameter) -> CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobScanBuilder.WithProtectionFlags(string? protectionFlags) -> CheatEngine.Client.Scanning.AobScanBuilder -CheatEngine.Client.Scanning.AobSingleMatchBuilder -CheatEngine.Client.Scanning.AobSingleMatchBuilder.AobSingleMatchBuilder() -> void -CheatEngine.Client.Scanning.AobSingleMatchBuilder.Equals(CheatEngine.Client.Scanning.AobSingleMatchBuilder other) -> bool -CheatEngine.Client.Scanning.AobSingleMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address -CheatEngine.Client.Scanning.AobSingleMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions -override CheatEngine.Client.Memory.MemoryAddressBuilder.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobFirstMatchBuilder.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobManyMatchBuilder.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobScanBuilder.GetHashCode() -> int -override CheatEngine.Client.Scanning.AobSingleMatchBuilder.GetHashCode() -> int -static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.At(this CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder -static CheatEngine.Client.Memory.Memory.At(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder -static CheatEngine.Client.Memory.Memory.At(CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder -static CheatEngine.Client.Memory.MemoryAddressBuilder.operator !=(CheatEngine.Client.Memory.MemoryAddressBuilder left, CheatEngine.Client.Memory.MemoryAddressBuilder right) -> bool -static CheatEngine.Client.Memory.MemoryAddressBuilder.operator ==(CheatEngine.Client.Memory.MemoryAddressBuilder left, CheatEngine.Client.Memory.MemoryAddressBuilder right) -> bool -static CheatEngine.Client.Scanning.AobFirstMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobFirstMatchBuilder left, CheatEngine.Client.Scanning.AobFirstMatchBuilder right) -> bool -static CheatEngine.Client.Scanning.AobFirstMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobFirstMatchBuilder left, CheatEngine.Client.Scanning.AobFirstMatchBuilder right) -> bool -static CheatEngine.Client.Scanning.AobManyMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobManyMatchBuilder left, CheatEngine.Client.Scanning.AobManyMatchBuilder right) -> bool -static CheatEngine.Client.Scanning.AobManyMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobManyMatchBuilder left, CheatEngine.Client.Scanning.AobManyMatchBuilder right) -> bool -static CheatEngine.Client.Scanning.AobScanBuilder.operator !=(CheatEngine.Client.Scanning.AobScanBuilder left, CheatEngine.Client.Scanning.AobScanBuilder right) -> bool -static CheatEngine.Client.Scanning.AobScanBuilder.operator ==(CheatEngine.Client.Scanning.AobScanBuilder left, CheatEngine.Client.Scanning.AobScanBuilder right) -> bool -static CheatEngine.Client.Scanning.AobSingleMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobSingleMatchBuilder left, CheatEngine.Client.Scanning.AobSingleMatchBuilder right) -> bool -static CheatEngine.Client.Scanning.AobSingleMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobSingleMatchBuilder left, CheatEngine.Client.Scanning.AobSingleMatchBuilder right) -> bool -static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.ICheatEngineClient! client, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder -static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.Scanning.IPatternScanner! scanner, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt index 3b8c4b1..93ab7a1 100644 --- a/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt @@ -1,29 +1,69 @@ #nullable enable -static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.Batch(this CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder -static CheatEngine.Client.Memory.Memory.Batch(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder +CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions +CheatEngine.Client.Memory.MemoryAddressBuilder +CheatEngine.Client.Memory.MemoryAddressBuilder.Address.get -> CheatEngine.SDK.Engine.Values.Address CheatEngine.Client.Memory.MemoryAddressBuilder.Follow(System.ReadOnlySpan offsets) -> CheatEngine.Client.Memory.MemoryPointerChainBuilder +CheatEngine.Client.Memory.MemoryAddressBuilder.MemoryAddressBuilder() -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.Read(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T CheatEngine.Client.Memory.MemoryAddressBuilder.ReadBytes(int length, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray CheatEngine.Client.Memory.MemoryAddressBuilder.ReadString(int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! -CheatEngine.Client.Memory.MemoryAddressBuilder.ReadUtf16(int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! -CheatEngine.Client.Memory.MemoryAddressBuilder.ReadUtf8(int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.MemoryAddressBuilder.TryRead(out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadBytes(int length, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadString(int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, out string? value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWrite(T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteBytes(System.ReadOnlySpan bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteString(string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.Write(T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void CheatEngine.Client.Memory.MemoryAddressBuilder.WriteBytes(System.ReadOnlySpan bytes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void CheatEngine.Client.Memory.MemoryAddressBuilder.WriteString(string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.MemoryAddressBuilder.WriteUtf16(string! value, int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void -CheatEngine.Client.Memory.MemoryAddressBuilder.WriteUtf8(string! value, int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void CheatEngine.Client.Memory.MemoryPointerChainBuilder CheatEngine.Client.Memory.MemoryPointerChainBuilder.MemoryPointerChainBuilder() -> void CheatEngine.Client.Memory.MemoryPointerChainBuilder.Request.get -> CheatEngine.Client.Memory.PointerChainRequest CheatEngine.Client.Memory.MemoryPointerChainBuilder.Resolve(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address CheatEngine.Client.Memory.MemoryPointerChainBuilder.TryResolve(out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool -CheatEngine.Client.Memory.MemoryPointerChainBuilder.Using(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPointerChainBuilder CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.MemoryPrimitiveBatchBuilder() -> void CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.Read(System.ReadOnlySpan addresses, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.TryRead(System.ReadOnlySpan addresses, out System.Collections.Immutable.ImmutableArray values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.TryWrite(System.ReadOnlySpan> values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.Write(System.ReadOnlySpan> values, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Scanning.AobFirstMatchBuilder +CheatEngine.Client.Scanning.AobFirstMatchBuilder.AobFirstMatchBuilder() -> void +CheatEngine.Client.Scanning.AobFirstMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.Client.Scanning.AobFirstMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address? address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.AobManyMatchBuilder +CheatEngine.Client.Scanning.AobManyMatchBuilder.AobManyMatchBuilder() -> void +CheatEngine.Client.Scanning.AobManyMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult +CheatEngine.Client.Scanning.AobManyMatchBuilder.TryExecute(out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.AlignedTo(int divisor) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.Alignment.get -> CheatEngine.Client.Scanning.ScanAlignment +CheatEngine.Client.Scanning.AobScanBuilder.AobScanBuilder() -> void CheatEngine.Client.Scanning.AobScanBuilder.Executable() -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.FirstOrNone() -> CheatEngine.Client.Scanning.AobFirstMatchBuilder +CheatEngine.Client.Scanning.AobScanBuilder.InModule(CheatEngine.SDK.Engine.Inspection.ModuleName module) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.InModule(string! moduleName) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.InRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.LastDigits(string! digits) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName? +CheatEngine.Client.Scanning.AobScanBuilder.Pattern.get -> CheatEngine.Client.Scanning.AobPattern +CheatEngine.Client.Scanning.AobScanBuilder.Protection.get -> CheatEngine.Client.Scanning.ScanProtectionFilter +CheatEngine.Client.Scanning.AobScanBuilder.Range.get -> CheatEngine.Client.Scanning.AobScanRange? +CheatEngine.Client.Scanning.AobScanBuilder.RequireSingle() -> CheatEngine.Client.Scanning.AobSingleMatchBuilder +CheatEngine.Client.Scanning.AobScanBuilder.Take(int maximumResults) -> CheatEngine.Client.Scanning.AobManyMatchBuilder +CheatEngine.Client.Scanning.AobScanBuilder.WithAlignment(CheatEngine.Client.Scanning.ScanAlignment alignment) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.WithProtection(CheatEngine.Client.Scanning.ScanProtectionFilter protection) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.Writable() -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobSingleMatchBuilder +CheatEngine.Client.Scanning.AobSingleMatchBuilder.AobSingleMatchBuilder() -> void +CheatEngine.Client.Scanning.AobSingleMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.AobSingleMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions +static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.At(this CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder +static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.Batch(this CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder +static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.Scanning.IPatternScanner! scanner, CheatEngine.Client.Scanning.AobPattern pattern) -> CheatEngine.Client.Scanning.AobScanBuilder +static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.Scanning.IPatternScanner! scanner, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder diff --git a/libs/CheatEngine.Client.Fluent/README.md b/libs/CheatEngine.Client.Fluent/README.md index 1135cbd..eee78d1 100644 --- a/libs/CheatEngine.Client.Fluent/README.md +++ b/libs/CheatEngine.Client.Fluent/README.md @@ -6,24 +6,90 @@ operations. It enriches the contracts in `CheatEngine.Client.Abstractions`; it does not execute Cheat Engine calls by itself. -The package currently provides AOB request builders and typed target-memory address builders. A -terminal builder delegates work to a caller-supplied `IPatternScanner` or `IMemoryClient`, usually -the services available from an activation-scoped `ICheatEngineClient`. +The package currently provides AOB request builders and typed target-memory address builders. Each +domain has one entry point, an extension method on the service that runs its terminal operations: +`scanner.Aob(pattern)` on an `IPatternScanner`, and `memory.At(address)` or `memory.Batch()` on an +`IMemoryClient`, usually `client.Patterns` and `client.Memory` of an activation-scoped +`ICheatEngineClient`. A builder is bound to that service when it is created and is never rebound. + +## Installation + +A plugin references [`CheatEngine.Client`](https://www.nuget.org/packages/CheatEngine.Client), which brings this +package at exactly its own version. The +[CheatEngine.Client README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/src/CheatEngine.Client/README.md) +gives the plugin project, the requirements (`net10.0`, C# 14, a .NET SDK 10.0.401 or later, Cheat Engine 7.7.0.10621 +x64, a direct `CheatEngine.SDK` reference in `[2.0.0, 3.0.0)`) and a minimal plugin. Reference +`CheatEngine.Client.Fluent` on its own only for code that builds requests against the contracts without Hosting, at the +same version as every other Client package: the seven packages ship in lockstep. + +## Example ```csharp +using System.Collections.Immutable; + +using CheatEngine.Client; using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Values; -Address address = client.Aob("48 8B ?? ?? ?? 89") - .InModule("game.exe") - .ReadableExecutable() - .RequireSingle() - .Execute(); - -client.Memory.At(address + 0x14).Write(999); +namespace MyPlugin; + +public sealed class ScoreModule : ICheatEngineClientModule +{ + public void OnEnabled(ICheatEngineClient client) + { + Address address = client.Patterns.Aob("48 8B ?? ?? ?? 89") + .InModule("game.exe") + .Executable() + .RequireSingle() + .Execute(); + + client.Memory.At(address + 0x14).Write(999); + + // Take(n) copies at most n addresses, and IsTruncated says whether more may exist; a batch then reads one + // primitive at each address in one dispatched call. + AobScanResult writers = client.Patterns.Aob("89 05 ?? ?? ?? ??").InModule("game.exe").Take(16).Execute(); + ImmutableArray operands = client.Memory.Batch().Read(writers.Matches.AsSpan()); + } + + public void OnDisabling(ICheatEngineClient client) + { + } +} ``` +`InModule(...)` and `InRange(...)` scope the scan with one rule on every route: a match must lie entirely inside the +module, and its start must lie in the range. On a qualified local target Cheat Engine runs an exhaustive MemScan +limited to the module intersected with the range; it blocks Cheat Engine's main thread and cannot be interrupted once +started. On a CEServer or file-as-process target, Cheat Engine runs one global `AOBScan` over the whole target and Core +applies the same rule while copying, which does not reduce Cheat Engine's scan time or memory. +`Take(n)`, `FirstOrNone()` (1) and `RequireSingle()` (2) bound only how many addresses Core copies; they never stop +Cheat Engine early. Every route copies at most 65,535 addresses, whatever `n`, as `AobScanRequest.MaximumResults` +documents. `IsTruncated` reports a copy that is not proven complete: one cut by either limit, or, on the bounded route, +a destination that filled up with rows outside the request while rows stayed unread. `FirstOrNone()` returns the first +element in Cheat Engine's result-list order, which Cheat Engine does not specify (not the lowest address, not the first +logical region), and never uses a "first found" scan. `RequireSingle()` copies up to two matches from an exhaustive +scan: two copied matches are `AmbiguousMatch`, and one copied match is unique only when every row Cheat Engine returned +was read and the copy is not truncated. Otherwise whether a second match exists is unknown, which is +`IndeterminateHostResult`, never `AmbiguousMatch`. + +The terminals read `IPatternScanner.ScanDetailed`, whose metrics say whether every row Cheat Engine returned was +read. `null`, `NotFound` and an empty `Take` result are factual zeros only: the scan succeeded, every row was read, +and none lay inside the request. + +| Route (`PatternScanScope`) | Factual zero | Never a zero | +|-----------------------------------|-----------------------------------------|--------------------------| +| `HostBoundedRange` | No in-bounds row (error text readable) | A failed scan | +| `GlobalHostScanWithManagedFilter` | Every listed row outside the request | `nil`, an unread row | +| `GlobalHostScan` | An empty list that Cheat Engine returns | `nil` (Cheat Engine 7.7) | + +A global scan for which Cheat Engine returns no result list is reported as `IndeterminateHostResult` (on Cheat +Engine 7.7 `AOBScan` returns `nil` for zero matches and for some host failures alike), and so is an empty copy that +did not read every row. A cancellation token cannot interrupt a scan that Cheat Engine has started. +`IPatternScanner.ScanDetailed` reports the route, the host outcome, the host result count, the examined, filtered and +copied counts, and the Cheat Engine scan time separately from the copy time. + ## Why This Project Exists The public API needs expressive construction of bounded requests without coupling application code @@ -42,48 +108,46 @@ Abstractions ← Fluent ## How It Improves CheatEngine.Client -- Represents operation configuration as immutable `readonly record struct` values rather than CE - handles or mutable builders. -- Validates and normalizes an AOB pattern and its options before a terminal operation is selected. +- Represents operation configuration as immutable, plain `readonly struct` builders rather than CE + handles or mutable builders. A builder declares no `Equals`, `GetHashCode`, `ToString` or equality + operators (only `System.ValueType`'s): compare the requests or addresses it carries. It has no + constructor beyond the implicit parameterless one, which yields the `default` value: that value has + no service, and every operation that runs or selects a terminal throws a documented + `InvalidOperationException` on it. +- Validates and normalizes an AOB pattern and its options before a terminal operation is selected: + `Executable()`, `Writable()` and `WithProtection(...)` set the protection filter, `AlignedTo(...)`, + `LastDigits(...)` and `WithAlignment(...)` the alignment rule. A shortcut is named like the + `ScanAlignment` factory it calls, and `With
/// -/// The SDK constructs a plugin through a parameterless factory and reuses that instance across enable/disable cycles. -/// This base class therefore creates a fresh validated provider and scope only from , when the -/// SDK has attached Lua, and disposes them before the SDK detaches Lua in . It does not create -/// a -/// Generic Host, discover assemblies, retain a Lua state, or cross an asynchronous boundary. +/// +/// The SDK constructs a plugin through a parameterless factory and reuses that instance across enable/disable +/// cycles. This base class therefore creates a fresh validated provider and scope only from +/// , when the SDK has attached Lua, and disposes them before the SDK detaches Lua in +/// . It does not create a Generic Host, discover assemblies, retain a Lua state, or cross +/// an asynchronous boundary. +/// +/// +/// Raw SDK escape hatch. A derived plugin also inherits CheatEngine.SDK's protected static +/// CheatEnginePlugin.Context, the raw SDK plugin context of the current enable (plugin id, SDK epoch, main +/// thread, shutdown token). It is outside every guarantee of the Client: activation epochs, main-thread dispatch, +/// failure classification, resource ownership and release, and redaction. Code that uses it, or any other +/// CheatEngine.SDK API directly, follows the CheatEngine.SDK contract instead. Use +/// and the it returns for Client work. +/// /// public abstract class CheatEngineClientPlugin : CheatEnginePlugin { @@ -36,18 +48,21 @@ protected CheatEngineClientPlugin() } /// Gets the client for the active enable epoch. - /// The plugin is not currently enabled. + /// The client of the current activation. + /// The plugin is not currently enabled. protected ICheatEngineClient GetRequiredClient() { return GetActiveClient(); } - /// Adds application services, explicit Client modules, codecs, and configuration sources for one activation. + /// Adds application services, explicit Client modules, logging, and configuration sources for one activation. + /// The registrations, configuration and logging of the activation being enabled. /// /// Do not build a provider here. The base class builds it after this method returns with scope and build validation /// enabled. Application services that use Client APIs should be scoped and receive their dependencies by constructor /// injection; the plugin itself is the one unavoidable composition boundary because SDK plugins use parameterless - /// construction. + /// construction. A memory codec is an ordinary application service: register it in + /// and pass it with each codec request. /// protected abstract void Configure(CheatEnginePluginBuilder builder); @@ -60,22 +75,51 @@ protected virtual void OnClientEnabled(ICheatEngineClient client) /// Runs before enabled Client modules are disabled in reverse registration order. /// The client for the current activation. + /// + /// When the plugin disables, it runs on Cheat Engine's main thread after + /// was cancelled and before the activation releases what it owns. + /// A call it makes on that thread can still read and change existing state and release leases, but cannot + /// create a lease, attach, run Lua, instructions or Auto Assembler scripts, or load or save a table: see the + /// deactivation callbacks of . + /// protected virtual void OnClientDisabling(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); } - /// + /// + /// Creates and publishes the activation of this enable: calls , builds and validates + /// the activation provider and scope, enables the Client modules in registration order, then calls + /// . + /// + /// + /// A failure at any step rolls the partial activation back before the failure is rethrown, so no activation is + /// published. + /// + /// + /// A Client activation of this plugin instance is already active, or Cheat Engine has not enabled a current + /// plugin context for it. + /// + /// + /// The that bound or configured are invalid, + /// for example an entry that is not a fully qualified + /// path or a value out of range. The partial + /// activation is rolled back first. + /// + /// + /// The activation failed and its rollback failed too: the activation failure comes first, then every rollback + /// failure. A failure whose rollback succeeded is rethrown unchanged. + /// protected sealed override void OnEnable() { if (Volatile.Read(ref _activation) is not null) { - throw new CheatEngineClientLifecycleException( - "EnableClient", - "The Cheat Engine client is already active for this plugin instance."); + throw new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Client.Activate", + "The Cheat Engine client is already active for this plugin instance.", null, + CheatEngineHostEffect.NotStarted).ToException(); } - CheatEnginePluginBuilder builder = new(); + CheatEnginePluginBuilder builder = new(GetType().Assembly); ActivationConstruction construction = new(builder); Activation? activation = null; @@ -85,8 +129,9 @@ protected sealed override void OnEnable() activation = CreateActivation(construction); Volatile.Write(ref _activation, activation); + LogIdentification(activation, GetType()); activation.Lifecycle.Enable(OnClientEnabled); - ClientHostingLog.ActivationEnabled(activation.Logger, activation.Client.Epoch); + SafeLog(activation, static (logger, epoch) => ClientHostingLog.ActivationEnabled(logger, epoch)); } catch (Exception enableFailure) { @@ -97,14 +142,30 @@ protected sealed override void OnEnable() else { Interlocked.CompareExchange(ref _activation, null, activation); - ClientHostingLog.ActivationRollingBack(activation.Logger, activation.Client.Epoch); + SafeLog(activation, static (logger, epoch) => ClientHostingLog.ActivationRollingBack(logger, epoch)); RethrowAfterCleanup(enableFailure, CleanupActivation(activation)); } } } - /// + /// + /// Closes the activation of this enable, when there is one: calls , disables + /// the enabled modules in reverse order and releases the Client-owned Cheat Engine resources while Lua is + /// attached, then disposes the activation scope, the provider and the configuration. + /// + /// + /// Every stage is attempted even after an earlier one failed. A lease that the application did not release + /// completely is reported here, never to the code that released it. + /// + /// + /// A Client-owned resource was not released completely, and nothing else failed: the failure has the host + /// effect . + /// + /// + /// Several cleanup failures, one per failed stage or incomplete release; a single failure of another stage (a + /// module callback, for example) is rethrown unchanged. + /// protected sealed override void OnDisable() { Activation? activation = Interlocked.Exchange(ref _activation, null); @@ -113,15 +174,17 @@ protected sealed override void OnDisable() return; } - ClientHostingLog.ActivationDisabling(activation.Logger, activation.Client.Epoch); + SafeLog(activation, static (logger, epoch) => ClientHostingLog.ActivationDisabling(logger, epoch)); List failures = CleanupActivation(activation); if (failures.Count > 0) { - ClientHostingLog.ActivationCleanupFailed(activation.Logger, activation.Client.Epoch, failures.Count); + int failureCount = failures.Count; + SafeLog(activation.Logger, (Epoch: activation.Client.Epoch, Count: failureCount), + static (logger, state) => ClientHostingLog.ActivationCleanupFailed(logger, state.Epoch, state.Count)); } else { - ClientHostingLog.ActivationDisabled(activation.Logger, activation.Client.Epoch); + SafeLog(activation, static (logger, epoch) => ClientHostingLog.ActivationDisabled(logger, epoch)); } ThrowCleanupFailures(failures); @@ -203,59 +266,108 @@ private static List CleanupUnpublishedActivation(ActivationConstructi /// Module callbacks and Client-owned Cheat Engine resources run while the SDK context is valid. The activation scope, /// root provider, and configuration are then released in that order. Each stage is attempted even after an earlier /// stage fails, allowing the caller to report one aggregate failure only after all owned resources had a cleanup - /// opportunity. + /// opportunity. Each failed stage is logged with its stable stage name and the exception type name only (Q43, + /// Q46); a throwing logging provider cannot abort the remaining stages. Between the Client-owned resources and the + /// scope, the external Lua state reset warning is logged when CheatEngine.SDK reports one. /// private List CleanupActivation(Activation activation) { - List failures = []; + CleanupReport report = new(activation.Logger, activation.Client.Epoch); try { + report.Attempt(CleanupStage.CleanupScope); using (activation.Cleanup.EnterCleanupScope()) { - failures.AddRange(activation.Lifecycle.Cleanup(OnClientDisabling)); - try - { - activation.Cleanup.DrainOwnedResourcesForDisable(); - } - catch (Exception exception) - { - failures.Add(exception); - } + report.Record(CleanupStage.ModuleCallbacks, activation.Lifecycle.Cleanup(OnClientDisabling)); + report.Run(CleanupStage.ClientResources, activation.Cleanup.DrainOwnedResourcesForDisable); } } catch (Exception exception) { - failures.Add(exception); + report.Fail(CleanupStage.CleanupScope, exception); } - try - { - activation.Scope.Dispose(); - } - catch (Exception exception) - { - failures.Add(exception); - } + LogExternalLuaStateReset(activation); + report.Run(CleanupStage.Scope, activation.Scope.Dispose); + report.Run(CleanupStage.Provider, activation.Provider.Dispose); + report.Run(CleanupStage.Configuration, activation.Builder.ReleaseConfiguration); + report.Complete(); + return report.Failures; + } + + /// + /// Logs the identity of this activation once per enable (EventId 20): the Client version, the consumed and loaded + /// CheatEngine.SDK identity, how the loaded one relates to the consumed one, and the supported host profile. + /// Assembly metadata only, never a path or a Lua call. + /// + private static void LogIdentification(Activation activation, Type pluginType) + { + SafeLog(activation.Logger, (Epoch: activation.Client.Epoch, PluginType: pluginType), static (logger, state) => + { + const string NotDeclared = "unknown"; + ConsumedSdkIdentity identity = ConsumedSdkIdentity.Current; + string clientVersion = typeof(CheatEngineClientPlugin).Assembly + .GetCustomAttribute()?.InformationalVersion ?? NotDeclared; + ClientHostingLog.ActivationIdentified(logger, state.Epoch, state.PluginType.FullName ?? state.PluginType.Name, + clientVersion, identity.Version ?? NotDeclared, identity.ContentHashSha512 ?? NotDeclared, + identity.LoadedInformationalVersion ?? NotDeclared, identity.IdentityLabel, + identity.PackageGate.State, ConsumedSdkIdentity.SupportedHostProfileId); + }); + } + /// + /// Warns (event 8) when CheatEngine.SDK detected an external Lua state reset during this activation (A8). + /// + /// + /// + /// The SDK's fact is sticky until the next enable. It is read once per cleanup, after the module callbacks and + /// the Client-owned resource releases, whose Lua admissions are the last ones the activation makes and can be + /// the ones that detect the reset, and before the scope and provider that own the logger are disposed. With + /// the reset, those Lua-bound releases were refused rather than made into the replacement state. + /// + /// + /// The read goes through the cleanup bridge, a lock-free read of the SDK's flag, never through the runtime + /// snapshot: once CheatEngine.SDK detected the reset it refuses every Lua admission, the snapshot's included, + /// so a snapshot can never report the reset. The read is diagnostics only: a read or a logging provider that + /// throws changes no cleanup outcome. + /// + /// + private static void LogExternalLuaStateReset(Activation activation) + { try { - activation.Provider.Dispose(); + if (activation.Cleanup.ExternalLuaStateResetDetected) + { + SafeLog(activation, static (logger, epoch) => ClientHostingLog.ExternalLuaStateResetDetected(logger, epoch)); + } } - catch (Exception exception) + catch (Exception) { - failures.Add(exception); + // Deliberately ignored: diagnostics must never change the lifecycle outcome. } + } + /// Writes a lifecycle event without letting a logging provider fault escape the plugin callback. + private static void SafeLog(Activation activation, Action log) + { + SafeLog(activation.Logger, activation.Client.Epoch, log); + } + + /// Writes a lifecycle event without letting a logging provider fault escape the plugin callback. + /// + /// Logging is best effort: a provider that throws must neither abort enable/disable cleanup nor cross the Cheat + /// Engine plugin callback (audit ch.24, "handlers never re-enter or throw across the ABI callback"). + /// + private static void SafeLog(ILogger logger, TState state, Action log) + { try { - activation.Builder.ReleaseConfiguration(); + log(logger, state); } - catch (Exception exception) + catch (Exception) { - failures.Add(exception); + // Deliberately ignored: diagnostics must never change the lifecycle outcome. } - - return failures; } private static void ThrowCleanupFailures(List failures) @@ -293,9 +405,106 @@ private ICheatEngineClient GetActiveClient() return activation.Client; } - throw new CheatEngineClientLifecycleException( - "GetClient", - "The Cheat Engine client is available only while the plugin is enabled."); + throw new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Client.GetRequiredClient", + "The Cheat Engine client is available only while the plugin is enabled.", null, + CheatEngineHostEffect.NotStarted).ToException(); + } + + /// The stable, data-free names of the activation cleanup stages, in execution order. + private enum CleanupStage + { + CleanupScope, + ModuleCallbacks, + ClientResources, + Scope, + Provider, + Configuration + } + + /// Collects cleanup failures per stage and logs each failed stage without user data. + private sealed class CleanupReport(ILogger logger, long epoch) + { + private const int StageCount = (int) CleanupStage.Configuration + 1; + + private readonly bool[] _attempted = new bool[StageCount]; + private readonly bool[] _failed = new bool[StageCount]; + + internal List Failures + { + get; + } = []; + + internal void Attempt(CleanupStage stage) + { + _attempted[(int) stage] = true; + } + + internal void Run(CleanupStage stage, Action cleanup) + { + Attempt(stage); + try + { + cleanup(); + } + catch (Exception exception) + { + Fail(stage, exception); + } + } + + internal void Record(CleanupStage stage, List failures) + { + Attempt(stage); + foreach (Exception failure in failures) + { + Fail(stage, failure); + } + } + + internal void Fail(CleanupStage stage, Exception exception) + { + Failures.Add(exception); + _failed[(int) stage] = true; + string exceptionType = exception.GetType().FullName ?? exception.GetType().Name; + SafeLog(logger, (Epoch: epoch, Stage: GetName(stage), ExceptionType: exceptionType), + static (target, state) => + ClientHostingLog.ActivationCleanupStageFailed(target, state.Epoch, state.Stage, state.ExceptionType)); + } + + internal void Complete() + { + SafeLog(logger, (Epoch: epoch, Attempted: Count(_attempted), Failed: Count(_failed)), + static (target, state) => + ClientHostingLog.ActivationCleanupCompleted(target, state.Epoch, state.Attempted, state.Failed)); + } + + private static int Count(bool[] stages) + { + int count = 0; + foreach (bool stage in stages) + { + if (stage) + { + count++; + } + } + + return count; + } + + private static string GetName(CleanupStage stage) + { + return stage switch + { + CleanupStage.CleanupScope => "CleanupScope", + CleanupStage.ModuleCallbacks => "ModuleCallbacks", + CleanupStage.ClientResources => "ClientResources", + CleanupStage.Scope => "Scope", + CleanupStage.Provider => "Provider", + CleanupStage.Configuration => "Configuration", + _ => "Unknown" + }; + } } private sealed class ActivationConstruction(CheatEnginePluginBuilder builder) diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineHostLogExtensions.cs b/libs/CheatEngine.Client.Hosting/CheatEngineHostLogExtensions.cs new file mode 100644 index 0000000..38a7fc0 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEngineHostLogExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.Hosting; + +/// Adds the opt-in logging provider that writes to CheatEngine.SDK's host log. +/// +/// +/// The provider writes each admitted entry to CheatEngine.SDK.Hosting.Diagnostics.HostLog, whose default +/// sink is the Windows debug output of the Cheat Engine process. Nothing is added unless a plugin calls +/// builder.Logging.AddCheatEngineHostLog() in . +/// +/// +/// Levels map to the four host log levels: and to +/// Trace, to Information, to +/// Warning, and and to Error; +/// is never written. An entry is written only when the logging filters admit it and +/// HostLog.IsEnabled accepts its host level; HostLog.MinimumLevel is Information by +/// default. +/// +/// +/// By default an entry carries only the logger category, the event id, the message template and the exception +/// type name (audit Q46); writes the formatted +/// message and the exception instead. The template is written as the caller passed it, so a message built by +/// string interpolation carries its values: log through constant structured templates or LoggerMessage +/// methods to keep them out. The host log, its sink and its minimum level belong to CheatEngine.SDK and +/// are shared by every plugin that loads the same SDK assemblies. A sink that routes host log entries back into +/// a logger that uses this provider is contained: the host log drops the re-entrant entry instead of recursing. +/// +/// +public static class CheatEngineHostLogExtensions +{ + /// Adds the provider that writes message templates to CheatEngine.SDK's host log. + /// The logging builder, for example . + /// . + /// is . + /// The provider is added once: a later call, with or without options, adds nothing. + public static ILoggingBuilder AddCheatEngineHostLog(this ILoggingBuilder builder) + { + return builder.AddCheatEngineHostLog(static _ => + { + }); + } + + /// Adds the provider that writes to CheatEngine.SDK's host log, with explicit options. + /// The logging builder, for example . + /// Sets the options of the provider; it runs once, during this call. + /// . + /// + /// or is . + /// + /// + /// The provider is added once: when it is already registered, this call runs but + /// keeps the options of the first registration. + /// + public static ILoggingBuilder AddCheatEngineHostLog(this ILoggingBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + CheatEngineHostLogOptions options = new(); + configure(options); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton(new CheatEngineHostLogProvider(options.IncludeFormattedMessages))); + return builder; + } +} diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineHostLogOptions.cs b/libs/CheatEngine.Client.Hosting/CheatEngineHostLogOptions.cs new file mode 100644 index 0000000..881c66e --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEngineHostLogOptions.cs @@ -0,0 +1,34 @@ +namespace CheatEngine.Client.Hosting; + +/// Options of the opt-in logging provider that writes to CheatEngine.SDK's host log. +/// +/// They are set by the configuration delegate of . The level threshold +/// is not an option: an entry is written when the logging filters admit it and the host log accepts its level. +/// +public sealed class CheatEngineHostLogOptions +{ + /// Initializes the default options, which write message templates only. + public CheatEngineHostLogOptions() + { + } + + /// + /// Gets or sets whether an entry carries the formatted message and the exception instead of the message template. + /// + /// + /// by default (audit Q46): an entry then carries the logger category, the event id, the + /// message template with its placeholders (for example {Epoch}) and the exception type name, never a + /// placeholder value or an exception message, because those can hold addresses, values, symbol expressions, paths + /// or Lua text. The template is written as the caller passed it: a message built by string interpolation or + /// concatenation is its own template and carries its values, so plugin code keeps them out of the host log only + /// by logging constant structured templates or LoggerMessage methods, as the Client's own events do. Set it + /// to only to troubleshoot on a machine you control: the formatted message and the + /// exception text then reach the host log sink, by default the Windows debug output of the Cheat Engine process, + /// which any debugger or debug-output viewer of the session can read. + /// + public bool IncludeFormattedMessages + { + get; + set; + } +} diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineHostLogProvider.cs b/libs/CheatEngine.Client.Hosting/CheatEngineHostLogProvider.cs new file mode 100644 index 0000000..9bbb48c --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEngineHostLogProvider.cs @@ -0,0 +1,113 @@ +using CheatEngine.SDK.Hosting.Diagnostics; + +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.Hosting; + +/// Writes Microsoft.Extensions.Logging entries to CheatEngine.SDK's . +/// +/// +/// An entry is written as category[event id]: text. By default the text is the message template of the +/// entry, read from its {OriginalFormat} value, followed by the exception type name: argument values and +/// exception messages, which can hold user data, are never read (audit Q46). The template is the caller's text +/// as passed, so one built by string interpolation already carries its values; only constant structured +/// templates keep them out. An entry whose state carries no template is written as its category and event id +/// only. With formatted messages enabled, the text is the formatted message and the exception is passed to the +/// host log. +/// +/// +/// never throws and drops an entry that a sink writes back into it on the same +/// thread, so this provider cannot recurse through a sink that forwards host log entries to +/// ILogger. +/// +/// +internal sealed class CheatEngineHostLogProvider(bool includeFormattedMessages) : ILoggerProvider +{ + private const string OriginalFormatKey = "{OriginalFormat}"; + + /// Gets whether entries carry the formatted message and the exception instead of the template. + internal bool IncludeFormattedMessages => includeFormattedMessages; + + public ILogger CreateLogger(string categoryName) + { + return new HostLogLogger(categoryName ?? string.Empty, includeFormattedMessages); + } + + public void Dispose() + { + // The host log belongs to CheatEngine.SDK: this provider holds nothing to release. + } + + /// Maps a logging level to its host log level; becomes the highest, Error. + /// for and undefined levels, which are never written. + internal static bool TryMapLevel(LogLevel logLevel, out HostLogLevel hostLevel) + { + (bool mapped, hostLevel) = logLevel switch + { + LogLevel.Trace or LogLevel.Debug => (true, HostLogLevel.Trace), + LogLevel.Information => (true, HostLogLevel.Information), + LogLevel.Warning => (true, HostLogLevel.Warning), + LogLevel.Error or LogLevel.Critical => (true, HostLogLevel.Error), + _ => (false, HostLogLevel.Trace) + }; + return mapped; + } + + /// Reads the message template of a structured state without formatting any of its values. + internal static string? FindTemplate(TState state) + { + if (state is not IReadOnlyList> values) + { + return null; + } + + // Microsoft.Extensions.Logging places the template last; the loop tolerates a state that does not. + for (int index = values.Count - 1; index >= 0; index--) + { + KeyValuePair value = values[index]; + if (string.Equals(value.Key, OriginalFormatKey, StringComparison.Ordinal)) + { + return value.Value as string; + } + } + + return null; + } + + private sealed class HostLogLogger(string category, bool includeFormattedMessages) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return TryMapLevel(logLevel, out HostLogLevel hostLevel) && HostLog.IsEnabled(hostLevel); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + if (!TryMapLevel(logLevel, out HostLogLevel hostLevel) || !HostLog.IsEnabled(hostLevel)) + { + return; + } + + string prefix = $"{category}[{eventId.Id}]"; + if (includeFormattedMessages) + { + HostLog.Write(hostLevel, $"{prefix}: {formatter(state, exception)}", exception); + return; + } + + string? template = FindTemplate(state); + string text = template is null ? prefix : $"{prefix}: {template}"; + HostLog.Write(hostLevel, exception is null + ? text + : $"{text} ({exception.GetType().FullName ?? exception.GetType().Name})"); + } + } +} diff --git a/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs index 1914df3..549e619 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs +++ b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs @@ -1,34 +1,50 @@ +using System.Diagnostics.CodeAnalysis; + using CheatEngine.Client.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using ReflectionAssembly = System.Reflection.Assembly; namespace CheatEngine.Client.Hosting; /// Builds the managed configuration and service provider for one Cheat Engine plugin activation. /// -/// A builder is intentionally single-use. A new instance is created for every enable epoch so scoped Client services -/// cannot retain a Lua reference, CE object, cancellation token, or target-specific state from an earlier activation. +/// A builder is intentionally single-use. creates a new instance for every +/// enable epoch, passes it to and builds the activation provider +/// itself after that method returns, so scoped Client services cannot retain a Lua reference, CE object, +/// cancellation token, or target-specific state from an earlier activation. Application code never creates a +/// builder or a provider. /// public sealed class CheatEnginePluginBuilder { + private readonly ReflectionAssembly _pluginAssembly; private bool _built; - /// Creates an empty configuration and a service collection for one activation. - public CheatEnginePluginBuilder() + /// Creates an empty configuration and a service collection for one activation of a plugin. + /// The assembly of the concrete plugin, whose folder is . + internal CheatEnginePluginBuilder(ReflectionAssembly pluginAssembly) { + _pluginAssembly = pluginAssembly ?? throw new ArgumentNullException(nameof(pluginAssembly)); Services = new ServiceCollection(); Configuration = new ConfigurationManager(); Services.AddSingleton(Configuration); Services.AddSingleton(Configuration); + Logging = new PluginLoggingBuilder(Services); Client = Services.AddCheatEngineClient(Configuration); } /// Gets the mutable configuration manager used before the activation provider is built. /// /// No source is loaded implicitly. Callers can add an optional appsettings.json or other explicit sources - /// during . Reloading should remain disabled because an activation's - /// ownership, capabilities, and cancellation boundary cannot safely be reconfigured while attached to CE. + /// during , resolving file sources against + /// (for example with SetBasePath(builder.PluginDirectory)). Hosting cannot + /// set that base itself, and a file source added without it resolves against + /// , which under Cheat Engine's .NET host is not the plugin folder: an + /// optional file then silently loads nothing. Reloading should remain disabled because an activation's ownership, + /// capabilities, and cancellation boundary cannot safely be reconfigured while attached to CE. /// public ConfigurationManager Configuration { @@ -41,15 +57,51 @@ public IServiceCollection Services get; } + /// Gets the logging configuration of the new activation provider. + /// + /// Providers and filters added here receive the Hosting lifecycle events and the Client's Core diagnostic events + /// of this activation: both log through the activation provider's . Nothing is + /// written anywhere until a provider is added. + /// + public ILoggingBuilder Logging + { + get; + } + /// Gets the Client-specific registration builder. public CheatEngineClientBuilder Client { get; } + /// Gets the folder that holds the plugin assembly, the base for the plugin's configuration files. + /// + /// Cheat Engine starts a managed plugin through its .NET host, so describes + /// the hosting process rather than the plugin's deployment folder. Resolve the files deployed with the plugin, + /// such as appsettings.json, against this folder instead. + /// + /// + /// The plugin assembly was not loaded from a file (for example from memory or from a single-file bundle), so the + /// plugin has no folder. + /// + public string PluginDirectory + { + [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", + Justification = "ADR-02: Cheat Engine loads a managed plugin through the managed-hostfxr profile, from its " + + "deployment folder, never from a single-file bundle; an assembly without a file location " + + "is refused with an InvalidOperationException.")] + get + { + string location = _pluginAssembly.Location; + return (location.Length == 0 ? null : Path.GetDirectoryName(location)) + ?? throw new InvalidOperationException( + "The plugin assembly was not loaded from a file, so the plugin has no folder."); + } + } + /// Builds a validating provider after all explicit registrations have been added. /// The builder has already created its provider. - public ServiceProvider BuildServiceProvider() + internal ServiceProvider BuildServiceProvider() { if (_built) { @@ -59,7 +111,8 @@ public ServiceProvider BuildServiceProvider() _built = true; return Services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); } @@ -73,4 +126,13 @@ internal void ReleaseConfiguration() { Configuration.Dispose(); } + + /// The logging view of ; the Client registration adds the logging services. + private sealed class PluginLoggingBuilder(IServiceCollection services) : ILoggingBuilder + { + public IServiceCollection Services + { + get; + } = services; + } } diff --git a/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs b/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs index 4380ba4..b363c70 100644 --- a/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs +++ b/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs @@ -1,8 +1,16 @@ +using CheatEngine.Client.Runtime; + using Microsoft.Extensions.Logging; namespace CheatEngine.Client.Hosting; /// Source-generated lifecycle logging that intentionally excludes memory contents and Lua source. +/// +/// Redaction policy (audit Q46): events carry only the activation epoch, stable stage names, counts, and exception +/// type names. They never carry exception messages, addresses, values, symbol expressions, file paths, or Lua +/// text, which are user data. Event ids 1–5 are frozen; 6–19 are reserved for Hosting cleanup and redaction events; +/// 20–49 are activation identification events. +/// internal static partial class ClientHostingLog { [LoggerMessage(1, LogLevel.Debug, "Cheat Engine Client activation {Epoch} enabled.")] @@ -18,6 +26,42 @@ internal static partial class ClientHostingLog internal static partial void ActivationDisabled(ILogger logger, long epoch); [LoggerMessage(5, LogLevel.Warning, - "Cheat Engine Client activation {Epoch} completed cleanup with {FailureCount} callback failure(s).")] + "Cheat Engine Client activation {Epoch} completed cleanup with {FailureCount} failure(s).")] internal static partial void ActivationCleanupFailed(ILogger logger, long epoch, int failureCount); + + /// One cleanup stage failed; only its stable name and the exception type name are recorded. + [LoggerMessage(6, LogLevel.Warning, + "Cheat Engine Client activation {Epoch} cleanup stage {Stage} failed with {ExceptionType}.")] + internal static partial void ActivationCleanupStageFailed(ILogger logger, long epoch, string stage, + string exceptionType); + + /// Every cleanup stage was attempted; counts only. + [LoggerMessage(7, LogLevel.Debug, + "Cheat Engine Client activation {Epoch} attempted {AttemptedStages} cleanup stage(s); {FailedStages} failed.")] + internal static partial void ActivationCleanupCompleted(ILogger logger, long epoch, int attemptedStages, + int failedStages); + + /// + /// CheatEngine.SDK detected an external Lua state reset during the activation (A8), read once after the Client-owned + /// resources were released: Cheat Engine replaced its Lua state outside the plugin's control. Epoch only. + /// + [LoggerMessage(8, LogLevel.Warning, + "Cheat Engine Client activation {Epoch}: Cheat Engine replaced its Lua state outside the plugin's control. Lua " + + "work is refused until the next enable, and Lua-bound resources are not released into the new state.")] + internal static partial void ExternalLuaStateResetDetected(ILogger logger, long epoch); + + /// + /// Identifies the Client build, the consumed and loaded CheatEngine.SDK and the supported host profile once per + /// enable (audit A24-12). Built from assembly metadata only: no path, no file read and no Lua call. The identity + /// label says whether the loaded SDK is the reviewed package, another release the package gate accepts, or one + /// it does not accept. + /// + [LoggerMessage(20, LogLevel.Information, + "Cheat Engine Client activation {Epoch} enables {PluginType} with CheatEngine.Client {ClientVersion}; consumed " + + "CheatEngine.SDK {ConsumedSdkVersion} (NuGet content hash {ConsumedSdkContentHash}), loaded " + + "CheatEngine.SDK.Engine {LoadedSdkVersion} ({LoadedSdkIdentity}), package evidence {PackageEvidence}; " + + "supported host profile {SupportedHost}.")] + internal static partial void ActivationIdentified(ILogger logger, long epoch, string pluginType, + string clientVersion, string consumedSdkVersion, string consumedSdkContentHash, string loadedSdkVersion, + string loadedSdkIdentity, ClientCapabilityEvidenceState packageEvidence, string supportedHost); } diff --git a/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt index de1a7b3..7dc5c58 100644 --- a/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt @@ -1,15 +1 @@ #nullable enable -abstract CheatEngine.Client.Hosting.CheatEngineClientPlugin.Configure(CheatEngine.Client.Hosting.CheatEnginePluginBuilder! builder) -> void -CheatEngine.Client.Hosting.CheatEngineClientPlugin -CheatEngine.Client.Hosting.CheatEngineClientPlugin.CheatEngineClientPlugin() -> void -CheatEngine.Client.Hosting.CheatEngineClientPlugin.GetRequiredClient() -> CheatEngine.Client.ICheatEngineClient! -CheatEngine.Client.Hosting.CheatEnginePluginBuilder -CheatEngine.Client.Hosting.CheatEnginePluginBuilder.BuildServiceProvider() -> Microsoft.Extensions.DependencyInjection.ServiceProvider! -CheatEngine.Client.Hosting.CheatEnginePluginBuilder.CheatEnginePluginBuilder() -> void -CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Client.get -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! -CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Configuration.get -> Microsoft.Extensions.Configuration.ConfigurationManager! -CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -override sealed CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnDisable() -> void -override sealed CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnEnable() -> void -virtual CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnClientDisabling(CheatEngine.Client.ICheatEngineClient! client) -> void -virtual CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnClientEnabled(CheatEngine.Client.ICheatEngineClient! client) -> void diff --git a/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt index 7dc5c58..e988110 100644 --- a/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt @@ -1 +1,22 @@ #nullable enable +CheatEngine.Client.Hosting.CheatEngineClientPlugin +CheatEngine.Client.Hosting.CheatEngineClientPlugin.CheatEngineClientPlugin() -> void +CheatEngine.Client.Hosting.CheatEngineClientPlugin.GetRequiredClient() -> CheatEngine.Client.ICheatEngineClient! +CheatEngine.Client.Hosting.CheatEngineHostLogExtensions +CheatEngine.Client.Hosting.CheatEngineHostLogOptions +CheatEngine.Client.Hosting.CheatEngineHostLogOptions.CheatEngineHostLogOptions() -> void +CheatEngine.Client.Hosting.CheatEngineHostLogOptions.IncludeFormattedMessages.get -> bool +CheatEngine.Client.Hosting.CheatEngineHostLogOptions.IncludeFormattedMessages.set -> void +CheatEngine.Client.Hosting.CheatEnginePluginBuilder +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Client.get -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Configuration.get -> Microsoft.Extensions.Configuration.ConfigurationManager! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Logging.get -> Microsoft.Extensions.Logging.ILoggingBuilder! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.PluginDirectory.get -> string! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +abstract CheatEngine.Client.Hosting.CheatEngineClientPlugin.Configure(CheatEngine.Client.Hosting.CheatEnginePluginBuilder! builder) -> void +override sealed CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnDisable() -> void +override sealed CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnEnable() -> void +static CheatEngine.Client.Hosting.CheatEngineHostLogExtensions.AddCheatEngineHostLog(this Microsoft.Extensions.Logging.ILoggingBuilder! builder) -> Microsoft.Extensions.Logging.ILoggingBuilder! +static CheatEngine.Client.Hosting.CheatEngineHostLogExtensions.AddCheatEngineHostLog(this Microsoft.Extensions.Logging.ILoggingBuilder! builder, System.Action! configure) -> Microsoft.Extensions.Logging.ILoggingBuilder! +virtual CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnClientDisabling(CheatEngine.Client.ICheatEngineClient! client) -> void +virtual CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnClientEnabled(CheatEngine.Client.ICheatEngineClient! client) -> void diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md index 9497bb8..a3e6167 100644 --- a/libs/CheatEngine.Client.Hosting/README.md +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -12,6 +12,49 @@ concrete plugin, whose implicit public constructor may call it. The host is intentionally synchronous and in-process. It is not a Generic Host and does not create a process-wide service provider, retain a raw Lua state, discover services by reflection, or keep a configuration file watcher alive. +A plugin also inherits CheatEngine.SDK's `protected static` `CheatEnginePlugin.Context`, the raw SDK plugin context of +the current enable. It is a raw SDK escape hatch outside every guarantee of the Client (activation epochs, main-thread +dispatch, failure classification, resource ownership and release, redaction): code that uses it, or any other +CheatEngine.SDK API directly, follows the CheatEngine.SDK contract instead. + +## Installation + +A plugin references [`CheatEngine.Client`](https://www.nuget.org/packages/CheatEngine.Client), which brings this +package at exactly its own version, and `CheatEngine.SDK` directly: + +| Plugin project requirement | Value | +|---|---| +| Target framework | `net10.0` | +| Language | C# 14 (`14.0`) | +| .NET SDK | 10.0.401 or later: the Lua generator packed in this package is compiled against Roslyn 5.9.0; an older compiler does not run it and reports only warning CS9057 | +| Platform | Windows x64; `PlatformTarget` is `x64` or `AnyCPU` | +| Cheat Engine | 7.7.0.10621 x64 (`cheatengine-x86_64.exe`), loading the plugin through its managed .NET host | +| `CheatEngine.SDK` | A direct `PackageReference` in `[2.0.0, 3.0.0)` | + +```xml + + net10.0 + 14.0 + enable + enable + x64 + true + + + + + + + +``` + +Replace `X.Y.Z` with the installed CheatEngine.Client version. Keep `CheatEngine.SDK` on 2.x: this Client release is +built and tested against CheatEngine.SDK 2.0.0 and declares `[2.0.0, 3.0.0)`. A 3.x SDK fails the build with +`CECLIENT017`, and a version below 2.0.0 fails the restore with `NU1605`. The seven Client packages ship in lockstep: +reference this package directly only at the same version as `CheatEngine.Client`. +`CheatEngine.Client.Templates` (`dotnet new ceplugin`) writes this project with a complete plugin layout: a bounded +AOB, memory, Address List and Lua-module example. + ## Why this project exists Cheat Engine controls plugin construction and the point at which its Lua runtime is attached. Reusing a provider across @@ -37,14 +80,21 @@ disposes activation configuration. Cleanup failures are aggregated after all cle `CheatEnginePluginBuilder`, builds a new provider, and creates one activation scope from that provider. The Core Client graph intentionally uses provider-local singleton registrations, so **activation-local** means “owned by this new provider,” not “registered with Microsoft DI's `Scoped` lifetime.” A disable/re-enable cycle therefore constructs a -new Client graph, options cache, deterministic codecs, and module state without mechanically changing their DI -lifetimes. +new Client graph, options cache, and module state without mechanically changing their DI lifetimes. The Client +registers no memory codec: a codec is an application service that the plugin registers in `Services` and passes with +each codec request. + +A new provider per enable isolates this plugin's Client graph from its previous enable epochs; it does **not** isolate +state that lives outside the container. CheatEngine.SDK static state (`PluginHost` and the current plugin context) and +Cheat Engine's Lua globals are shared by every plugin that loads the same SDK assemblies into the Cheat Engine process, +and a DI container cannot separate them. Coexistence of two plugins that share or do not share the SDK assemblies is +the live scenario Q09 (two plugins in one Cheat Engine process), for which no Client receipt exists yet. Creating a second `IServiceScope` from the same provider does not create another Client activation. That second scope has its own scoped application services and modules, but shares the provider's singleton Client graph, options, and -codecs; scopes are siblings, not nested activation roots. Hosting opens exactly one such scope for an enable epoch. -An integrator that needs an external persistent root must first introduce and qualify an explicit activation-factory -design—repeated `CreateScope()` calls are not a supported substitute. +application singletons; scopes are siblings, not nested activation roots. Hosting opens exactly one such scope for an +enable epoch. An integrator that needs an external persistent root must first introduce and qualify an explicit +activation-factory design—repeated `CreateScope()` calls are not a supported substitute. The DI container owns the objects that it creates. Hosting never disposes resolved modules or services individually: after lifecycle callbacks and Client resource drain, it disposes the activation scope, then the provider, and finally @@ -60,12 +110,19 @@ attached as secondary diagnostics. No module callback or Client-owned resource d created and published. The DI container disposes services it creates; Hosting explicitly releases its host-created `ConfigurationManager` only after the scope and provider have been released. -Add configuration sources explicitly and keep reload disabled. The following is the normal plugin shape: +Add configuration sources explicitly and keep reload disabled. `CheatEnginePluginBuilder` exposes `Configuration`, +`Services`, `Logging` (the `ILoggingBuilder` of the activation provider), `Client` (the Client registrations) and +`PluginDirectory`, the folder of the plugin assembly. Resolve the files deployed with the plugin against +`PluginDirectory`: `AppContext.BaseDirectory` describes the Cheat Engine process that hosts .NET, not the plugin's +deployment folder. Hosting creates the builder and builds the provider itself; neither is public. The following is the +normal plugin shape: ```csharp using CheatEngine.Client; using CheatEngine.Client.Hosting; +using CheatEngine.Client.Modules; using CheatEngine.SDK.Annotations.Plugin; + using Microsoft.Extensions.Configuration; namespace MyPlugin; @@ -76,7 +133,7 @@ public sealed class Plugin : CheatEngineClientPlugin protected override void Configure(CheatEnginePluginBuilder builder) { builder.Configuration - .SetBasePath(AppContext.BaseDirectory) + .SetBasePath(builder.PluginDirectory) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); builder.Client.AddModule(); @@ -87,30 +144,57 @@ public sealed class Plugin : CheatEngineClientPlugin // Use the client only for this enable epoch. } } + +public sealed class MyClientModule : ICheatEngineClientModule +{ + public void OnEnabled(ICheatEngineClient client) + { + // Runs after the provider, the scope and the client of this enable exist, in registration order. + } + + public void OnDisabling(ICheatEngineClient client) + { + // Runs in reverse registration order, before Client-owned resources are released. + } +} ``` +## Build diagnostics + Plugin projects must reference `CheatEngine.SDK` directly as well as `CheatEngine.Client`. The SDK's plugin entry-point generator and native bridge build assets cannot be supplied through a transitive NuGet dependency. Set -`CheatEngineClientPluginProject` to `true` to opt into the Hosting profile. It verifies the two direct references -(`CECLIENT001`/`CECLIENT002`), exactly one attributed `CheatEngineClientPlugin` (`CECLIENT003`/`CECLIENT004`), -`net10.0`, C# 14, and an x64 or AnyCPU target (`CECLIENT005`–`CECLIENT007`). Disabling the SDK generator additionally -requires an explicit `CheatEngineClientManualBootstrap=true` acknowledgement; the SDK then validates the exact manual -entry point (`CECLIENT008` and `CESDK0003`). +`CheatEngineClientPluginProject` to `true` to opt into the Hosting plugin profile: the build targets this package +brings (`buildTransitive`) then report the diagnostics below, and nothing else. Every diagnostic's help link points to +its row. The profile checks run before compilation, the plugin type check right after it, the SDK major check once +package assets are resolved, and the deployment checks only when `CheatEnginePluginOutputPath` is set (see "Managed +deployment folder"). `CheatEngineClientSkipPluginProjectValidation=true` skips the profile and plugin type checks, and +`CheatEngineClientSkipDirectSdkReferenceCheck=true` skips `CECLIENT001` alone; neither is needed by a normal plugin. -```xml - - true - - - - - - - -``` +| Code | Severity | Reported when | Fix | +|---|---|---|---| +| `CECLIENT001` | Error | The plugin project has no direct `PackageReference` to `CheatEngine.SDK` | Reference `CheatEngine.SDK` 2.x directly: its entry-point generator and Lua bridge run only for a direct reference | +| `CECLIENT002` | Error | The plugin project has no direct `PackageReference` to `CheatEngine.Client`, for example when it references `CheatEngine.Client.Hosting` only | Reference `CheatEngine.Client` directly | +| `CECLIENT003` | Error | The compiled assembly declares no `[CheatEnginePlugin]` type derived from `CheatEngineClientPlugin` | Declare exactly one | +| `CECLIENT004` | Error | The compiled assembly declares more than one such type | Keep one plugin type per assembly | +| `CECLIENT005` | Error | `TargetFramework` is not exactly `net10.0`, for example `net10.0-windows` | Target `net10.0` | +| `CECLIENT006` | Error | `LangVersion` is not exactly `14.0` | Set `14.0` | +| `CECLIENT007` | Error | `PlatformTarget` is neither `x64` nor `AnyCPU`: Cheat Engine hosts the plugin in an x64 process | Set `x64` or `AnyCPU` | +| `CECLIENT008` | Error | `CheatEngineSdkGenerateEntryPoint=false` without `CheatEngineClientManualBootstrap=true` | Keep the generated entry point, or write the exact `CESDK.CESDK.CEPluginInitialize` bootstrap that `CESDK0003` validates and set `CheatEngineClientManualBootstrap=true` | +| `CECLIENT009` | Error | The plugin type check could not read the compiled assembly's metadata; the message gives the reason | Rebuild; report the message if it persists | +| `CECLIENT010` | Error | Deployment: the plugin assembly was not produced | Fix the build errors reported before it | +| `CECLIENT011` | Error | Deployment: no `.deps.json` next to the plugin assembly, for example with `GenerateDependencyFile=false` | Let the build generate the dependency manifest | +| `CECLIENT012` | Error | Deployment: no `.runtimeconfig.json` next to the plugin assembly, for example with `GenerateRuntimeConfigurationFiles=false` | Let the build generate the runtime configuration | +| `CECLIENT013` | Error | Deployment: `CheatEngine.SDK.dll` is not in the build output, for example with `CopyLocalLockFileAssemblies=false` | Reference `CheatEngine.SDK` directly and keep package assemblies copied to the output | +| `CECLIENT014` | Error | Deployment: `CheatEngine.Client.Hosting.dll` is not in the build output | Reference `CheatEngine.Client` and keep package assemblies copied to the output | +| `CECLIENT015` | Error | Deployment: `cheatengine-sdk-lua-bridge.dll` is not in the build output, because the direct `CheatEngine.SDK` build asset did not copy it | Reference `CheatEngine.SDK` directly and keep its bridge `Content` item | +| `CECLIENT016` | Error | Deployment: the output holds no managed file, or staging or replacing a destination file failed; the message gives the reason | Check the destination folder and deploy while the plugin is disabled | +| `CECLIENT017` | Error; Warning with `CheatEngineClientAllowUnsupportedSdk=true` | The plugin resolves a `CheatEngine.SDK` major this release does not support (3.x or later) | Reference `CheatEngine.SDK` 2.x, or a Client release that supports that SDK | -`CheatEngine.Client.Templates` contains a complete plugin layout that applies this configuration and includes a bounded -AOB, memory, Address List, and Lua-module example. +A `CheatEngine.SDK` below 2.0.0 never reaches these checks: the restore fails with `NU1605`. The SDK's own build and +analyzer diagnostics (`CESDK...`) are documented in the +[CheatEngine.SDK repository](https://github.com/CheatEngineNet/CheatEngine.SDK/tree/main/analyzers/docs), and those of +the Lua module generator this package carries (`CECLUA...`) in its +[README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md#diagnostics). ## Managed deployment folder @@ -122,8 +206,90 @@ dotnet build .\MyPlugin.csproj --configuration Release ` ``` `PrepareCheatEnginePluginDeployment` runs only for the marked plugin profile and only when that property is non-empty. -It validates the plugin DLL, manifests, Client/SDK managed closure, and the SDK Lua bridge, stages the complete output, -then replaces each destination file with Windows write-through replacement semantics. It does not inspect or change a -Cheat Engine installation, runtime configuration, or plugin list. Windows cannot atomically replace a non-empty -directory, so deploy while the plugin is disabled; destination files are individually never copied in a partially -written state. +It validates the plugin DLL, manifests, Client/SDK managed closure, and the SDK Lua bridge before anything is written +(`CECLIENT010` to `CECLIENT015`), stages the complete output, then replaces each destination file with Windows +write-through replacement semantics (`CECLIENT016` when that fails). It does not inspect or change a Cheat Engine +installation, runtime configuration, or plugin list. Windows cannot atomically replace a non-empty directory, so deploy +while the plugin is disabled; destination files are individually never copied in a partially written state. + +## Cleanup diagnostics and redaction + +Disable runs every cleanup stage even after an earlier stage fails, in this order: `CleanupScope` (the main-thread +cleanup scope), `ModuleCallbacks` (application hook, then modules in reverse order), `ClientResources` (Client-owned +Cheat Engine resources, while the SDK context is still attached), then `Scope`, `Provider`, and `Configuration`. One +failure is rethrown unchanged; several are reported together as one `AggregateException` in attempt order. Core applies +the same rule to its own resource registries, so a faulty module or lease never prevents the next release. + +Once per enable, before the modules start, event 20 (`ActivationIdentified`, Information) identifies the activation: +epoch, plugin type name, CheatEngine.Client version, the consumed CheatEngine.SDK version and NuGet content hash +embedded at build time, the informational version of the loaded `CheatEngine.SDK.Engine` with a label that says whether +it is the reviewed package, another release of the supported major that the package gate accepts, or a release outside +that range, the package evidence state, and the supported host profile id `ce-7.7.0.10621-x64-managed-hostfxr`. It is +built from assembly metadata only: no path, no file read, and no Lua call. The Core diagnostic events (1000–1800) are +described in the +[`CheatEngine.Client.Core` README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Core/README.md#diagnostics-events). + +Each failed stage is logged as event 6 with the activation epoch, the stable stage name, and the exception **type** +name only; event 7 reports how many stages were attempted and how many failed; event 5 reports the failure count. +Hosting never logs exception messages, addresses, values, symbol expressions, file paths, or Lua text: those are user +data and belong to the application's explicit opt-in; `CheatEngineFailure.ToString()` follows the same rule. Logging is +best effort: a logging provider that throws cannot abort enable, disable, or any remaining cleanup stage. + +After the `ClientResources` stage and before the scope is disposed, Hosting reads CheatEngine.SDK's sticky external +Lua state reset fact once. When CheatEngine.SDK detected, during the activation or during those releases, that Cheat +Engine replaced its Lua state outside the plugin's control, event 8 (`ExternalLuaStateResetDetected`, Warning, epoch +only) says so: Lua work is refused until the next enable, and the Lua-bound releases were refused rather than made into +the replacement state. The read is a lock-free flag read, not a runtime snapshot: once CheatEngine.SDK detected the +reset it refuses every Lua admission, the snapshot's included. A read that fails changes nothing. A reset that +CheatEngine.SDK first detects when it detaches Lua, after the Client cleanup, is reported only by the SDK's own +`LuaStateReplacedExternally:` host log line. + +## Cheat Engine host log (opt-in) + +`builder.Logging.AddCheatEngineHostLog()` adds a logging provider that writes to CheatEngine.SDK's host log +(`CheatEngine.SDK.Hosting.Diagnostics.HostLog`), whose default sink is the Windows debug output of the Cheat Engine +process, shown by an attached debugger or a debug-output viewer. Nothing is added by default. + +- Levels map to the four host log levels: `Trace` and `Debug` to `Trace`, `Information` to `Information`, `Warning` to + `Warning`, and `Error` and `Critical` to `Error`; `None` is never written. An entry is written only when the logging + filters admit it **and** `HostLog.IsEnabled` accepts its host level. `HostLog.MinimumLevel` is `Information` by + default, so `Debug` and `Trace` entries need `HostLog.MinimumLevel = HostLogLevel.Trace`. +- By default an entry is `category[event id]: template`, the **message template** of the entry (for example + `Cheat Engine Client activation {Epoch} enabled.`), followed by the exception type name. Placeholder values and + exception messages are never written, because they can hold addresses, values, symbol expressions, paths, or Lua text + (Q46). The template is written as the caller passed it: a message built by string interpolation, such as + `logger.LogInformation($"Read {address}")`, is its own template and carries its values. Plugin code keeps them out of + the host log only by logging constant structured templates or `LoggerMessage` methods, as the Client's own events do. + `AddCheatEngineHostLog(options => options.IncludeFormattedMessages = true)` writes the formatted message and the + exception instead; use it only to troubleshoot on a machine you control. +- The provider is added once: a later call adds nothing and keeps the options of the first call. +- The host log, its sink, and its minimum level belong to CheatEngine.SDK and are shared by every plugin that loads the + same SDK assemblies. A sink that routes host log entries back into `ILogger` is contained: the host log drops the + re-entrant entry instead of recursing. + +CheatEngine.SDK can also write its own bounded identification line, `CheatEngineSdkIdentification` (SDK version, native +bridge fingerprint, bound Lua module hash, Cheat Engine and runtime versions, never a user path), at the start of every +enable attempt. It is off by default. Set `HostLog.IdentifyOnEnable = true` from a `[ModuleInitializer]` method, which +runs before any plugin code, so that it also covers the first enable; or set the environment variable +`CHEATENGINE_SDK_IDENTIFY_ON_ENABLE=1` for the Cheat Engine process, without rebuilding the plugin. Either one is +enough. The Client's own identification is event 20 above. A plugin assembly is the application Cheat Engine loads, so +the library warning CA2255 on a module initializer does not apply to it: + +```csharp +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +using CheatEngine.SDK.Hosting.Diagnostics; + +namespace MyPlugin; + +internal static class HostLogSetup +{ + [ModuleInitializer] + [SuppressMessage("Usage", "CA2255", Justification = "A plugin assembly is the application Cheat Engine loads.")] + internal static void IdentifyEveryEnable() + { + HostLog.IdentifyOnEnable = true; + } +} +``` diff --git a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets index 0e58943..3502f1b 100644 --- a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets +++ b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets @@ -4,6 +4,19 @@ packages: the Client describes the high-level activation contract while the SDK supplies the entry point generator and the native Lua protection bridge. Neither responsibility may depend on NuGet asset transitivity. --> + + + + <_CheatEngineClientHelpLink>https://github.com/CheatEngineNet/CheatEngine.Client + <_CheatEngineClientHelpLink>$(_CheatEngineClientHelpLink)/blob/main/libs + <_CheatEngineClientHelpLink>$(_CheatEngineClientHelpLink)/CheatEngine.Client.Hosting + <_CheatEngineClientHelpLink>$(_CheatEngineClientHelpLink)/README.md# + + @@ -17,24 +30,72 @@ + + + <_CheatEngineClientSdkUpperMajor>3 + + + + + <_CheatEngineClientResolvedSdkAsset Remove="@(_CheatEngineClientResolvedSdkAsset)"/> + <_CheatEngineClientResolvedSdkAsset Include="@(ResolvedCompileFileDefinitions)" + Condition="'%(ResolvedCompileFileDefinitions.NuGetPackageId)' == 'CheatEngine.SDK'"/> + <_CheatEngineClientResolvedSdkAsset Include="@(RuntimeCopyLocalItems)" + Condition="'%(RuntimeCopyLocalItems.NuGetPackageId)' == 'CheatEngine.SDK'"/> + + + <_CheatEngineClientResolvedSdkVersion>@(_CheatEngineClientResolvedSdkAsset->'%(NuGetPackageVersion)'->Distinct()) + <_CheatEngineClientResolvedSdkMajor>$([System.Text.RegularExpressions.Regex]::Match('$(_CheatEngineClientResolvedSdkVersion)', '^[0-9]+').Value) + <_CheatEngineClientUnsupportedSdk>false + <_CheatEngineClientUnsupportedSdk Condition="'$(_CheatEngineClientResolvedSdkMajor)' != '' and $(_CheatEngineClientResolvedSdkMajor) >= $(_CheatEngineClientSdkUpperMajor)">true + + + + + + + + + diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaDiagnostics.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaDiagnostics.cs new file mode 100644 index 0000000..5fafdbd --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaDiagnostics.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// +/// Every diagnostic the Client Lua generator reports. Ids come from the CECLUA ranges allocated to this generator and are +/// never renumbered or reused; each one is tracked in AnalyzerReleases.*.md and listed in the generator README. +/// +internal static class CheatEngineLuaDiagnostics +{ + internal const string Category = "CheatEngine.Client.Lua"; + + internal const string HelpLinkUri = + "https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md#diagnostics"; + + public static readonly DiagnosticDescriptor InvalidModuleShape = new( + "CECLUA1001", "Lua module must be a non-static partial class", + "[CheatEngineLuaModule] requires a top-level, concrete, non-static, non-generic, non-file-local partial class", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor InvalidBindingsType = new( + "CECLUA1002", "Lua module requires a static SDK bindings type", + "The bindings type for [CheatEngineLuaModule] must be a non-generic, non-file-local static class that owns SDK-generated registration methods", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor NoExports = new( + "CECLUA1003", "Lua module bindings export nothing", + "The bindings type '{0}' does not declare a [LuaFunction] export", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor InvalidExportSet = new( + "CECLUA1004", "Lua module exports must have unique names", + "The Lua module bindings contain an invalid or duplicate export name: '{0}'", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor InvalidModuleName = new( + "CECLUA1005", "Lua module name cannot be blank", + "The explicit [CheatEngineLuaModule] name cannot be empty or whitespace", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor NoPublicConstructor = new( + "CECLUA1006", "Lua module needs a public constructor", + "[CheatEngineLuaModule] cannot provide a safe public constructor because '{0}' declares constructors with no public accessibility; expose a public DI constructor or remove the explicit constructors so the generator can provide one", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor InvalidOperationShape = new( + "CECLUA1101", "Lua operation requires a supported SDK global declaration", + "[CheatEngineLuaOperation] requires a static partial [LuaGlobal] method in a top-level, non-generic, non-file-local static partial class", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor OverloadedOperation = new( + "CECLUA1102", "Lua operation method cannot be overloaded", + "Lua operation '{0}' is overloaded; use distinct method names so generated operation factories remain unambiguous", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor UnsupportedSignature = new( + "CECLUA1103", "Lua operation has an unsupported result shape", + "Lua operation '{0}' must have scalar input arguments and exactly one return value or one trailing out result", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor MapperRequired = new( + "CECLUA1104", "Lua operation result requires a mapper", + "Lua operation '{0}' returns a non-scalar SDK value; declare [CheatEngineLuaOperation(typeof(TMapper))] with an ILuaResultMapper implementation", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor InvalidMapper = new( + "CECLUA1105", "Lua operation mapper does not match the SDK result", + "Mapper '{0}' does not implement ILuaResultMapper for the result of Lua operation '{1}'", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor UnsafeMappedType = new( + "CECLUA1106", "Lua operation mapper must project a safe Client result", + "Lua operation '{0}' maps an unsafe value across the Client boundary: {1}", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor DuplicateModuleExport = new( + "CECLUA1201", "Lua export is owned by more than one Lua module", + "Lua global '{0}' is exported by Lua module '{1}' and again by Lua module '{2}'; a Lua global has a single owning module per plugin assembly, so remove the export from one of the bindings types", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor ReservedModuleMember = new( + "CECLUA1202", "Lua module declares a member reserved by the generated registration", + "Lua module '{0}' declares '{1}', which is reserved by the generated ownership-aware registration; rename or remove the member", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor InheritedModuleImplementation = new( + "CECLUA1203", "Lua module inherits a Lua module implementation", + "Lua module '{0}' derives from '{1}', which already implements a Lua module; the generated ownership state of one of them would be bypassed, so derive the module from object", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + public static readonly DiagnosticDescriptor LookAlikeAnnotation = new( + "CECLUA1204", "Lua module annotation is not the contract type", + "'{0}' is declared in assembly '{1}' instead of the contract assembly '{2}'; the annotation is not a Lua module contract and no module code is generated", + Category, DiagnosticSeverity.Error, true, helpLinkUri: HelpLinkUri); + + /// Gets every descriptor, ordered by id. + public static ImmutableArray All + { + get; + } = + [ + InvalidModuleShape, InvalidBindingsType, NoExports, InvalidExportSet, InvalidModuleName, NoPublicConstructor, + InvalidOperationShape, OverloadedOperation, UnsupportedSignature, MapperRequired, InvalidMapper, + UnsafeMappedType, DuplicateModuleExport, ReservedModuleMember, InheritedModuleImplementation, + LookAlikeAnnotation + ]; + + /// Resolves a descriptor from the id stored in an equatable pipeline model. + public static DiagnosticDescriptor Get(string id) + { + foreach (DiagnosticDescriptor descriptor in All) + { + if (string.Equals(descriptor.Id, id, StringComparison.Ordinal)) + { + return descriptor; + } + } + + throw new ArgumentOutOfRangeException(nameof(id), id, "Unknown CheatEngine.Client.Lua diagnostic id."); + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs index f7428c0..a5f796d 100644 --- a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; -using System.Text; + +using CheatEngine.Client.SourceGenerators.Lua.Model; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -8,11 +9,13 @@ namespace CheatEngine.Client.SourceGenerators.Lua; /// -/// Emits the Client-facing, handle-free adapters for explicitly declared SDK Lua binding types. +/// Emits the Client-facing, handle-free adapters for explicitly declared SDK Lua binding types, and the per-assembly +/// registrar through which generated modules use CheatEngine.SDK Lua registration leases. /// /// /// The generator is deliberately attribute-driven. It neither scans assemblies nor persists compilation state: -/// every pipeline transform is static and converts Roslyn symbols into immutable value data before emission. +/// every pipeline transform is static and converts Roslyn symbols into equatable value models (strings, copied +/// locations, ) before emission, so unrelated edits leave the pipeline cached. /// [Generator(LanguageNames.CSharp)] public sealed class CheatEngineLuaGenerator : IIncrementalGenerator @@ -23,8 +26,8 @@ public sealed class CheatEngineLuaGenerator : IIncrementalGenerator internal const string LuaOperationAttributeMetadataName = "CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute"; - private const string LuaFunctionAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute"; - private const string LuaGlobalAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute"; + internal const string LuaFunctionAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute"; + internal const string LuaGlobalAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute"; private const string LuaResultMapperMetadataName = "CheatEngine.Client.Lua.ILuaResultMapper"; private const string LuaClassAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaClassAttribute"; private const string CheatEngineSdkAssemblyPrefix = "CheatEngine.SDK"; @@ -35,31 +38,14 @@ public sealed class CheatEngineLuaGenerator : IIncrementalGenerator private const int ClientBoundaryMaximumDepth = 32; private const int ClientBoundaryMaximumNodes = 256; - private static readonly HashSet ApprovedSdkClientResultTypes = new(StringComparer.Ordinal) - { - "CheatEngine.SDK.Engine.AddressList.MemoryRecordId", - "CheatEngine.SDK.Engine.Enums.FastScanMethod", - "CheatEngine.SDK.Engine.Enums.VariableType", - "CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions", - "CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo", - "CheatEngine.SDK.Engine.Inspection.ModuleInfo", - "CheatEngine.SDK.Engine.Inspection.ModuleName", - "CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo", - "CheatEngine.SDK.Engine.Inspection.SymbolExpression", - "CheatEngine.SDK.Engine.Inspection.SymbolInfo", - "CheatEngine.SDK.Engine.Inspection.TargetProcessId", - "CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", - "CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", - "CheatEngine.SDK.Engine.Runtime.PointerSize", - "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability", - "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId", - "CheatEngine.SDK.Engine.Runtime.TargetAbi", - "CheatEngine.SDK.Engine.Scanning.Aob.AobPattern", - "CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions", - "CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest", - "CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest", - "CheatEngine.SDK.Engine.Values.Address" - }; + // Hint names use the namespace-qualified metadata name: keyword identifiers are not escaped ('@' is not allowed). + private static readonly SymbolDisplayFormat HintNameFormat = new( + SymbolDisplayGlobalNamespaceStyle.Omitted, + SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces); + + // The allowlist has a single source, ApprovedSdkClientTypes.cs, which CheatEngine.Client.Tests links unchanged. + private static readonly HashSet ApprovedSdkClientResultTypes = + new(ApprovedSdkClientTypes.Names, StringComparer.Ordinal); // The generated Client contract is an ownership boundary, so framework provenance is not sufficient proof // that a value is safe to expose. Keep this deliberately small and require all contained type arguments to @@ -111,342 +97,122 @@ public sealed class CheatEngineLuaGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { - IncrementalValuesProvider modules = context.SyntaxProvider + IncrementalValuesProvider modules = context.SyntaxProvider .ForAttributeWithMetadataName( LuaModuleAttributeMetadataName, static (node, _) => node is ClassDeclarationSyntax, - static (attributeContext, _) => ModuleCandidate.Create(attributeContext)) + static (attributeContext, _) => ModuleParser.Parse(attributeContext)) .WithTrackingName("CheatEngineLuaModule"); - context.RegisterSourceOutput(modules, static (productionContext, candidate) => + context.RegisterSourceOutput(modules, static (productionContext, model) => { - if (candidate.Diagnostic is not null) + foreach (DiagnosticInfo diagnostic in model.Diagnostics) { - productionContext.ReportDiagnostic(candidate.Diagnostic); - return; + productionContext.ReportDiagnostic(diagnostic.ToDiagnostic()); } - productionContext.AddSource(candidate.HintName, EmitModule(candidate)); + if (model.IsValid) + { + productionContext.AddSource(model.HintName, ModuleEmitter.Emit(model)); + } }); - IncrementalValuesProvider operations = context.SyntaxProvider + // CECLUA1201: one owning module per Lua global per plugin assembly (Q16). The models are equatable, so the + // collected batch stays cached while no module changes. + context.RegisterSourceOutput(modules.Collect(), static (productionContext, models) => + { + foreach (DiagnosticInfo diagnostic in FindDuplicateExports(models)) + { + productionContext.ReportDiagnostic(diagnostic.ToDiagnostic()); + } + }); + + // One registrar and one CheatEngine.SDK adapter per assembly that declares a valid module; constant text, so the + // boolean input keeps this output cached while any valid module exists. + IncrementalValueProvider declaresModule = modules.Collect() + .Select(static (models, _) => models.Any(static model => model.IsValid)) + .WithTrackingName("CheatEngineLuaModuleRegistrar"); + context.RegisterSourceOutput(declaresModule, static (productionContext, emit) => + { + if (emit) + { + productionContext.AddSource(RegistrarEmitter.RegistrarHintName, RegistrarEmitter.RegistrarSource); + productionContext.AddSource(RegistrarEmitter.AdapterHintName, RegistrarEmitter.AdapterSource); + } + }); + + IncrementalValuesProvider operations = context.SyntaxProvider .ForAttributeWithMetadataName( LuaOperationAttributeMetadataName, static (node, _) => node is MethodDeclarationSyntax, - static (attributeContext, _) => OperationCandidate.Create(attributeContext)) + static (attributeContext, _) => OperationParser.Parse(attributeContext)) .WithTrackingName("CheatEngineLuaOperation"); - context.RegisterSourceOutput(operations, static (productionContext, candidate) => + context.RegisterSourceOutput(operations, static (productionContext, model) => { - if (candidate.Diagnostic is not null) + foreach (DiagnosticInfo diagnostic in model.Diagnostics) { - productionContext.ReportDiagnostic(candidate.Diagnostic); - return; + productionContext.ReportDiagnostic(diagnostic.ToDiagnostic()); } - productionContext.AddSource(candidate.HintName, EmitOperation(candidate)); + if (model.IsValid) + { + productionContext.AddSource(model.HintName, OperationEmitter.Emit(model)); + } }); } - private static string EmitModule(ModuleCandidate candidate) + /// + /// Reports every export that a later module (by file path, then position) publishes again. The order is + /// deterministic and independent of the order in which the models were collected. + /// + internal static ImmutableArray FindDuplicateExports(ImmutableArray models) { - SourceBuilder source = new(); - source.WriteGeneratedHeader(); - source.OpenNamespace(candidate.Namespace); - source.WriteLine(candidate.TypeDeclaration + " : global::CheatEngine.Client.Lua.IDescribedLuaModule"); - source.OpenBlock(); - if (candidate.EmitsPublicParameterlessConstructor) + ModuleModel[] valid = [.. models.Where(static model => model.IsValid)]; + Array.Sort(valid, CompareDeclarationOrder); + Dictionary owners = new(StringComparer.Ordinal); + ImmutableArray.Builder diagnostics = ImmutableArray.CreateBuilder(); + foreach (ModuleModel model in valid) { - source.WriteLine( - "/// Initializes a Lua module instance for activation-scoped dependency injection."); - source.WriteLine("public " + candidate.ModuleTypeName + "()"); - source.OpenBlock(); - source.CloseBlock(); - source.WriteLine(); - } + foreach (string export in model.Exports) + { + if (owners.TryGetValue(export, out ModuleModel? owner)) + { + diagnostics.Add(DiagnosticInfo.Create(CheatEngineLuaDiagnostics.DuplicateModuleExport, model.Location, + export, owner.ModuleDisplayName, model.ModuleDisplayName)); + continue; + } - source.WriteLine("private static readonly global::CheatEngine.Client.Lua.LuaModuleDescriptor s_descriptor ="); - source.Indent(); - source.WriteLine("new global::CheatEngine.Client.Lua.LuaModuleDescriptor("); - source.Indent(); - source.WriteLine(CSharpLiteral(candidate.ModuleName) + ","); - source.WriteLine(EmitExports(candidate.Exports) + ");"); - source.Unindent(); - source.Unindent(); - source.WriteLine(); - source.WriteLine("/// "); - source.WriteLine("public global::CheatEngine.Client.Lua.LuaModuleDescriptor Descriptor => s_descriptor;"); - source.WriteLine(); - source.WriteLine("/// "); - source.WriteLine("public void Register()"); - source.OpenBlock(); - source.WriteLine("using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation ="); - source.Indent(); - source.WriteLine("global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation();"); - source.Unindent(); - source.WriteLine("EnsureExportsAreVacant(operation.State);"); - source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = " + candidate.BindingsType + - ".RegisterLuaFunctions(operation.State);"); - source.WriteLine("if (status.IsOk)"); - source.OpenBlock(); - source.WriteLine("return;"); - source.CloseBlock(); - source.WriteLine(); - source.WriteLine("try"); - source.OpenBlock(); - source.WriteLine("status.ThrowIfFailed(operation.State);"); - source.CloseBlock(); - source.WriteLine("finally"); - source.OpenBlock(); - source.WriteLine("_ = " + candidate.BindingsType + ".UnregisterLuaFunctions(operation.State);"); - source.CloseBlock(); - source.CloseBlock(); - source.WriteLine(); - source.WriteLine("/// "); - source.WriteLine("public void Unregister()"); - source.OpenBlock(); - source.WriteLine("using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation ="); - source.Indent(); - source.WriteLine("global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation();"); - source.Unindent(); - source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = " + candidate.BindingsType + - ".UnregisterLuaFunctions(operation.State);"); - source.WriteLine("if (!status.IsOk)"); - source.OpenBlock(); - source.WriteLine("status.ThrowIfFailed(operation.State);"); - source.CloseBlock(); - source.CloseBlock(); - source.WriteLine(); - source.WriteLine( - "private static void EnsureExportsAreVacant(global::CheatEngine.SDK.Lua.State.LuaState state)"); - source.OpenBlock(); - source.WriteLine("int top = state.Top;"); - source.WriteLine("try"); - source.OpenBlock(); - foreach (string export in candidate.Exports) - { - source.OpenBlock(); - source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = state.TryGetGlobal(" + - CSharpLiteral(export) + "u8);"); - source.WriteLine("if (!status.IsOk)"); - source.OpenBlock(); - source.WriteLine("status.ThrowIfFailed(state);"); - source.CloseBlock(); - source.WriteLine("if (!state.IsNil(-1))"); - source.OpenBlock(); - source.WriteLine("throw new global::System.InvalidOperationException(" + - CSharpLiteral("Lua global '" + export + - "' is already defined and cannot be replaced by Client module '" + - candidate.ModuleName + "'.") + ");"); - source.CloseBlock(); - source.WriteLine("state.Pop(1);"); - source.CloseBlock(); + owners.Add(export, model); + } } - source.CloseBlock(); - source.WriteLine("finally"); - source.OpenBlock(); - source.WriteLine("state.SetTop(top);"); - source.CloseBlock(); - source.CloseBlock(); - source.CloseBlock(); - - return source.ToString(); + return diagnostics.ToImmutable(); } - private static string EmitOperation(OperationCandidate candidate) + private static int CompareDeclarationOrder(ModuleModel left, ModuleModel right) { - SourceBuilder source = new(); - source.WriteGeneratedHeader(); - source.OpenNamespace(candidate.Namespace); - source.WriteLine(candidate.ContainingTypeDeclaration); - source.OpenBlock(); - source.WriteLine("/// Creates a handle-free Client operation for " + candidate.MethodName + - "."); - source.WriteLine("public static " + candidate.OperationTypeName + " Create" + candidate.OperationTypeName + - "(" + - EmitParameterList(candidate.Parameters) + ")"); - source.OpenBlock(); - source.WriteLine("return new " + candidate.OperationTypeName + "(" + - EmitArgumentList(candidate.Parameters, false) + ");"); - source.CloseBlock(); - source.WriteLine(); - source.WriteLine("/// Generated readonly value operation for " + candidate.MethodName + - "."); - source.WriteLine(candidate.OperationVisibility + " readonly record struct " + candidate.OperationTypeName + - "(" + - EmitRecordParameterList(candidate.Parameters) + - ") : global::CheatEngine.Client.Lua.ILuaOperation<" + - candidate.ResultType + ">"); - source.OpenBlock(); - source.WriteLine("/// "); - source.WriteLine("public bool TryExecute(global::CheatEngine.Client.Lua.ILuaExecutionContext context, out " + - candidate.ResultType + - " result, out global::CheatEngine.Client.Results.CheatEngineFailure failure)"); - source.OpenBlock(); - source.WriteLine("global::System.ArgumentNullException.ThrowIfNull(context);"); - source.WriteLine("context.ThrowIfExpired();"); - source.WriteLine("try"); - source.OpenBlock(); - if (candidate.HasOutResult) - { - source.WriteLine(candidate.SourceResultType + " source;"); - source.WriteLine("if (!" + candidate.BindingsType + "." + candidate.MethodName + "(" + - EmitOutArgumentList(candidate.Parameters) + "))"); - source.OpenBlock(); - source.WriteLine("result = default!;"); - source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); - source.Indent(); - source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError,"); - source.WriteLine(CSharpLiteral(candidate.FailureOperation) + ","); - source.WriteLine( - CSharpLiteral("The SDK Lua Try binding returned false without exposing an unsafe Lua handle.") + ");"); - source.Unindent(); - source.WriteLine("return false;"); - source.CloseBlock(); - } - else + int order = string.CompareOrdinal(left.Location?.FilePath, right.Location?.FilePath); + if (order == 0) { - source.WriteLine(candidate.SourceResultType + " source = " + candidate.BindingsType + "." + - candidate.MethodName + "(" + EmitArgumentList(candidate.Parameters, true) + ");"); + order = (left.Location?.TextSpan.Start ?? -1).CompareTo(right.Location?.TextSpan.Start ?? -1); } - source.WriteLine(candidate.MapperType is null - ? "result = source;" - : "result = " + candidate.MapperType + ".Map(source);"); - source.WriteLine("failure = default;"); - source.WriteLine("return true;"); - source.CloseBlock(); - source.WriteLine("catch (global::CheatEngine.SDK.Lua.Calls.LuaException exception)"); - source.OpenBlock(); - source.WriteLine("result = default!;"); - source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); - source.Indent(); - source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError,"); - source.WriteLine(CSharpLiteral(candidate.FailureOperation) + ","); - source.WriteLine("exception.Message,"); - source.WriteLine("exception);"); - source.Unindent(); - source.WriteLine("return false;"); - source.CloseBlock(); - source.WriteLine("catch (global::System.Exception exception)"); - source.OpenBlock(); - source.WriteLine("result = default!;"); - source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); - source.Indent(); - source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.BindingError,"); - source.WriteLine(CSharpLiteral(candidate.FailureOperation) + ","); - source.WriteLine("exception.Message,"); - source.WriteLine("exception);"); - source.Unindent(); - source.WriteLine("return false;"); - source.CloseBlock(); - source.CloseBlock(); - source.CloseBlock(); - source.CloseBlock(); - - return source.ToString(); + return order != 0 ? order : string.CompareOrdinal(left.ModuleDisplayName, right.ModuleDisplayName); } - private static string EmitExports(ImmutableArray exports) - { - if (exports.IsEmpty) - { - return - "global::System.Collections.Immutable.ImmutableArray.Empty"; - } - - StringBuilder source = new("global::System.Collections.Immutable.ImmutableArray.Create("); - for (int index = 0; index < exports.Length; index++) - { - if (index != 0) - { - source.Append(", "); - } - - source.Append("new global::CheatEngine.Client.Lua.LuaExportDescriptor("); - source.Append(CSharpLiteral(exports[index])); - source.Append(')'); - } - - source.Append(')'); - return source.ToString(); - } - - private static string EmitParameterList(ImmutableArray parameters) - { - return string.Join(", ", parameters.Select(static parameter => parameter.Type + " " + parameter.Name)); - } - - private static string EmitRecordParameterList(ImmutableArray parameters) - { - return string.Join(", ", parameters.Select(static parameter => parameter.Type + " " + parameter.FieldName)); - } - - private static string EmitArgumentList(ImmutableArray parameters, bool fields) - { - return string.Join(", ", parameters.Select(parameter => fields ? parameter.FieldName : parameter.Name)); - } - - private static string EmitOutArgumentList(ImmutableArray parameters) - { - string arguments = EmitArgumentList(parameters, true); - return arguments.Length == 0 ? "out source" : arguments + ", out source"; - } - - private static string CSharpLiteral(string value) + internal static string CSharpLiteral(string value) { return SymbolDisplay.FormatLiteral(value, true); } - private static ImmutableArray GetExports(INamedTypeSymbol bindings, out string? invalidExport) - { - ImmutableArray.Builder exports = ImmutableArray.CreateBuilder(); - HashSet names = new(StringComparer.Ordinal); - foreach (IMethodSymbol method in bindings.GetMembers().OfType()) - { - AttributeData? attribute = GetAttribute(method, LuaFunctionAttributeMetadataName); - if (attribute is null) - { - continue; - } - - string? name = attribute.ConstructorArguments.Length == 1 - ? attribute.ConstructorArguments[0].Value as string - : null; - if (name is null) - { - invalidExport = "(missing name)"; - return ImmutableArray.Empty; - } - - if (string.IsNullOrWhiteSpace(name)) - { - invalidExport = name; - return ImmutableArray.Empty; - } - - string exportName = name; - if (!names.Add(exportName)) - { - invalidExport = exportName; - return ImmutableArray.Empty; - } - - exports.Add(exportName); - } - - invalidExport = null; - return exports.ToImmutable(); - } - - private static bool TryGetMapperResult(INamedTypeSymbol mapper, ITypeSymbol source, out ITypeSymbol result) + internal static bool TryGetMapperResult(INamedTypeSymbol mapper, ITypeSymbol source, out ITypeSymbol result) { foreach (INamedTypeSymbol contract in mapper.AllInterfaces) { if (!string.Equals(contract.OriginalDefinition.ToDisplayString(), LuaResultMapperMetadataName, - StringComparison.Ordinal) || - !SymbolEqualityComparer.Default.Equals(contract.TypeArguments[0], source)) + StringComparison.Ordinal) || + !SymbolEqualityComparer.Default.Equals(contract.TypeArguments[0], source)) { continue; } @@ -459,12 +225,12 @@ private static bool TryGetMapperResult(INamedTypeSymbol mapper, ITypeSymbol sour return false; } - private static bool HasAttribute(ISymbol symbol, string metadataName) + internal static bool HasAttribute(ISymbol symbol, string metadataName) { return GetAttribute(symbol, metadataName) is not null; } - private static AttributeData? GetAttribute(ISymbol symbol, string metadataName) + internal static AttributeData? GetAttribute(ISymbol symbol, string metadataName) { foreach (AttributeData attribute in symbol.GetAttributes()) { @@ -477,12 +243,12 @@ private static bool HasAttribute(ISymbol symbol, string metadataName) return null; } - private static bool IsPartial(INamedTypeSymbol type) + internal static bool IsPartial(INamedTypeSymbol type) { foreach (SyntaxReference declaration in type.DeclaringSyntaxReferences) { if (declaration.GetSyntax() is TypeDeclarationSyntax syntax && - syntax.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword))) + syntax.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword))) { return true; } @@ -491,7 +257,7 @@ private static bool IsPartial(INamedTypeSymbol type) return false; } - private static bool IsScalar(ITypeSymbol type) + internal static bool IsScalar(ITypeSymbol type) { if (type.TypeKind == TypeKind.Pointer || type.IsRefLikeType) { @@ -510,7 +276,7 @@ SpecialType.System_String or SpecialType.System_IntPtr or SpecialType.System_UIn }; } - private static bool TryFindClientBoundaryViolation(ITypeSymbol type, ClientBoundaryRole role, + internal static bool TryFindClientBoundaryViolation(ITypeSymbol type, ClientBoundaryRole role, out string violation) { HashSet visited = new(SymbolEqualityComparer.Default); @@ -540,7 +306,7 @@ private static bool TryFindClientBoundaryViolation(ITypeSymbol type, ClientBound return true; } - if (type.TypeKind == TypeKind.Error || type.TypeKind == TypeKind.Dynamic) + if (type.TypeKind is TypeKind.Error or TypeKind.Dynamic) { violation = path + " uses an unresolved or dynamic type."; return true; @@ -588,10 +354,10 @@ private static bool TryFindClientBoundaryViolation(ITypeSymbol type, ClientBound } foreach (ITypeParameterSymbol parameter in named.OriginalDefinition.TypeParameters - .OrderBy(static candidate => candidate.Ordinal)) + .OrderBy(static candidate => candidate.Ordinal)) { if (TryFindConstraintViolation(parameter, role, path + "." + parameter.Name, visited, ref visitedCount, - depth + 1, out violation)) + depth + 1, out violation)) { return true; } @@ -602,7 +368,7 @@ private static bool TryFindClientBoundaryViolation(ITypeSymbol type, ClientBound foreach (IFieldSymbol element in named.TupleElements) { if (TryFindClientBoundaryViolation(element.Type, role, path + "." + element.Name, visited, - ref visitedCount, depth + 1, out violation)) + ref visitedCount, depth + 1, out violation)) { return true; } @@ -612,14 +378,14 @@ private static bool TryFindClientBoundaryViolation(ITypeSymbol type, ClientBound foreach (ITypeSymbol argument in named.TypeArguments) { if (TryFindClientBoundaryViolation(argument, role, path + "<" + TypeName(argument) + ">", visited, - ref visitedCount, depth + 1, out violation)) + ref visitedCount, depth + 1, out violation)) { return true; } } if (IsFrameworkOrApprovedSdkValue(named) || - (role == ClientBoundaryRole.MapperSource && IsCheatEngineSdkType(named))) + (role == ClientBoundaryRole.MapperSource && IsCheatEngineSdkType(named))) { violation = string.Empty; return false; @@ -633,9 +399,9 @@ private static bool TryFindNamedTypeViolation(INamedTypeSymbol type, ClientBound { string metadataName = type.OriginalDefinition.ToDisplayString(); if (metadataName is "CheatEngine.SDK.Lua.State.LuaState" or "CheatEngine.SDK.Lua.References.LuaRef" or - "CheatEngine.SDK.Engine.Objects.CEObject" or "CheatEngine.SDK.Engine.Objects.Owned" || - type.ContainingNamespace.ToDisplayString().Contains(".Interop", StringComparison.Ordinal) || - HasAttribute(type, LuaClassAttributeMetadataName) || ImplementsSdkObjectContract(type)) + "CheatEngine.SDK.Engine.Objects.CEObject" or "CheatEngine.SDK.Engine.Objects.Owned" || + type.ContainingNamespace.ToDisplayString().Contains(".Interop", StringComparison.Ordinal) || + HasAttribute(type, LuaClassAttributeMetadataName) || ImplementsSdkObjectContract(type)) { violation = path + " exposes forbidden SDK lifetime or interop type '" + metadataName + "'."; return true; @@ -648,8 +414,8 @@ private static bool TryFindNamedTypeViolation(INamedTypeSymbol type, ClientBound } if (role == ClientBoundaryRole.ClientResult && - type.ContainingAssembly?.Name.StartsWith(CheatEngineSdkAssemblyPrefix, StringComparison.Ordinal) == true && - !ApprovedSdkClientResultTypes.Contains(metadataName)) + type.ContainingAssembly?.Name.StartsWith(CheatEngineSdkAssemblyPrefix, StringComparison.Ordinal) == true && + !ApprovedSdkClientResultTypes.Contains(metadataName)) { violation = path + " exposes non-approved SDK type '" + metadataName + "'."; return true; @@ -677,7 +443,7 @@ private static bool TryFindConstraintViolation(ITypeParameterSymbol typeParamete foreach (ITypeSymbol constraint in typeParameter.ConstraintTypes.OrderBy(TypeName, StringComparer.Ordinal)) { if (TryFindClientBoundaryViolation(constraint, role, path + " constraint", visited, ref visitedCount, - depth + 1, out violation)) + depth + 1, out violation)) { return true; } @@ -691,12 +457,12 @@ private static bool TryFindUserDefinedDtoViolation(INamedTypeSymbol type, Client HashSet visited, ref int visitedCount, int depth, out string violation) { if (type.BaseType is - { - SpecialType: not SpecialType.System_Object and not SpecialType.System_ValueType and - not SpecialType.System_Enum - } baseType && - TryFindClientBoundaryViolation(baseType, role, path + ".base", visited, ref visitedCount, depth + 1, - out violation)) + { + SpecialType: not SpecialType.System_Object and not SpecialType.System_ValueType and + not SpecialType.System_Enum + } baseType && + TryFindClientBoundaryViolation(baseType, role, path + ".base", visited, ref visitedCount, depth + 1, + out violation)) { return true; } @@ -709,20 +475,20 @@ not SpecialType.System_Enum } if (TryFindClientBoundaryViolation(implementedInterface, role, path + ".interface", visited, - ref visitedCount, depth + 1, out violation)) + ref visitedCount, depth + 1, out violation)) { return true; } } foreach (ISymbol member in type.GetMembers().OrderBy(static candidate => candidate.MetadataName, - StringComparer.Ordinal)) + StringComparer.Ordinal)) { switch (member) { case IFieldSymbol { IsStatic: false } field: if (TryFindClientBoundaryViolation(field.Type, role, path + "." + field.Name, visited, - ref visitedCount, depth + 1, out violation)) + ref visitedCount, depth + 1, out violation)) { return true; } @@ -730,9 +496,9 @@ not SpecialType.System_Enum break; case IPropertySymbol { IsStatic: false } property: if (TryFindClientBoundaryViolation(property.Type, role, path + "." + property.Name, visited, - ref visitedCount, depth + 1, out violation) || - TryFindParameterViolation(property.Parameters, role, path + "." + property.Name, visited, - ref visitedCount, depth + 1, out violation)) + ref visitedCount, depth + 1, out violation) || + TryFindParameterViolation(property.Parameters, role, path + "." + property.Name, visited, + ref visitedCount, depth + 1, out violation)) { return true; } @@ -740,21 +506,21 @@ not SpecialType.System_Enum break; case IEventSymbol { IsStatic: false } @event: if (TryFindClientBoundaryViolation(@event.Type, role, path + "." + @event.Name, visited, - ref visitedCount, depth + 1, out violation)) + ref visitedCount, depth + 1, out violation)) { return true; } break; case IMethodSymbol { IsStatic: false } method when !method.IsImplicitlyDeclared && - method.DeclaredAccessibility != - Accessibility.Private: + method.DeclaredAccessibility != + Accessibility.Private: violation = string.Empty; if (method.ReturnsByRef || method.ReturnsByRefReadonly || - TryFindClientBoundaryViolation(method.ReturnType, role, path + "." + method.Name, visited, - ref visitedCount, depth + 1, out violation) || - TryFindParameterViolation(method.Parameters, role, path + "." + method.Name, visited, - ref visitedCount, depth + 1, out violation)) + TryFindClientBoundaryViolation(method.ReturnType, role, path + "." + method.Name, visited, + ref visitedCount, depth + 1, out violation) || + TryFindParameterViolation(method.Parameters, role, path + "." + method.Name, visited, + ref visitedCount, depth + 1, out violation)) { violation = string.IsNullOrEmpty(violation) ? path + "." + method.Name + " exposes a by-reference return." @@ -782,7 +548,7 @@ private static bool TryFindParameterViolation(ImmutableArray p } if (TryFindClientBoundaryViolation(parameter.Type, role, path + " parameter '" + parameter.Name + "'", - visited, ref visitedCount, depth + 1, out violation)) + visited, ref visitedCount, depth + 1, out violation)) { return true; } @@ -795,28 +561,23 @@ private static bool TryFindParameterViolation(ImmutableArray p private static bool IsFrameworkOrApprovedSdkValue(INamedTypeSymbol type) { string metadataName = type.OriginalDefinition.ToDisplayString(); - if (ApprovedSdkClientResultTypes.Contains(metadataName)) - { - return true; - } - - return IsApprovedFrameworkClientBoundaryType(type); + return ApprovedSdkClientResultTypes.Contains(metadataName) || IsApprovedFrameworkClientBoundaryType(type); } private static bool IsApprovedFrameworkClientBoundaryType(INamedTypeSymbol type) { string metadataName = type.OriginalDefinition.ToDisplayString(); return IsScalar(type) || type.IsTupleType || ApprovedFrameworkValueTypes.Contains(metadataName) || - ApprovedFrameworkGenericCollectionTypes.Contains(metadataName); + ApprovedFrameworkGenericCollectionTypes.Contains(metadataName); } private static bool IsFrameworkType(INamedTypeSymbol type) { string? assemblyName = type.ContainingAssembly?.Name; return assemblyName is not null && - !assemblyName.StartsWith(CheatEngineSdkAssemblyPrefix, StringComparison.Ordinal) && - (assemblyName.StartsWith("System", StringComparison.Ordinal) || - assemblyName.StartsWith("Microsoft", StringComparison.Ordinal)); + !assemblyName.StartsWith(CheatEngineSdkAssemblyPrefix, StringComparison.Ordinal) && + (assemblyName.StartsWith("System", StringComparison.Ordinal) || + assemblyName.StartsWith("Microsoft", StringComparison.Ordinal)); } private static bool IsSafeFrameworkDtoImplementationContract(INamedTypeSymbol type) @@ -838,555 +599,38 @@ private static bool ImplementsSdkObjectContract(INamedTypeSymbol type) StringComparison.Ordinal)); } - private static string TypeDeclaration(INamedTypeSymbol type, bool isStatic) + internal static string TypeDeclaration(INamedTypeSymbol type, bool isStatic) { string accessibility = type.DeclaredAccessibility == Accessibility.Public ? "public" : "internal"; return accessibility + (isStatic ? " static" : string.Empty) + " partial class " + EscapeIdentifier(type.Name); } - private static string TypeName(ITypeSymbol type) + /// + /// The hint-name stem of a type: its namespace-qualified metadata name with dots replaced by underscores. Keyword + /// identifiers are not escaped, because a hint name cannot contain '@'. + /// + internal static string HintNameStem(INamedTypeSymbol type) + { + return type.ToDisplayString(HintNameFormat).Replace('.', '_'); + } + + internal static string TypeName(ITypeSymbol type) { return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier)); } - private static string EscapeIdentifier(string name) + internal static string EscapeIdentifier(string name) { return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || - SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None + SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None ? "@" + name : name; } - private enum ClientBoundaryRole + internal enum ClientBoundaryRole { MapperSource, ClientResult } - - private sealed class ModuleCandidate - { - private ModuleCandidate(Diagnostic? diagnostic, string hintName, string @namespace, string typeDeclaration, - string moduleTypeName, string bindingsType, string moduleName, ImmutableArray exports, - bool emitsPublicParameterlessConstructor) - { - Diagnostic = diagnostic; - HintName = hintName; - Namespace = @namespace; - TypeDeclaration = typeDeclaration; - ModuleTypeName = moduleTypeName; - BindingsType = bindingsType; - ModuleName = moduleName; - Exports = exports; - EmitsPublicParameterlessConstructor = emitsPublicParameterlessConstructor; - } - - public Diagnostic? Diagnostic - { - get; - } - - public string HintName - { - get; - } - - public string Namespace - { - get; - } - - public string TypeDeclaration - { - get; - } - - public string ModuleTypeName - { - get; - } - - public string BindingsType - { - get; - } - - public string ModuleName - { - get; - } - - public ImmutableArray Exports - { - get; - } - - public bool EmitsPublicParameterlessConstructor - { - get; - } - - public static ModuleCandidate Create(GeneratorAttributeSyntaxContext context) - { - INamedTypeSymbol? module = context.TargetSymbol as INamedTypeSymbol; - Location location = context.TargetNode.GetLocation(); - if (module is null || module.TypeKind != TypeKind.Class || module.IsStatic || module.IsAbstract || - module.IsFileLocal || module.ContainingType is not null || - module.OriginalDefinition.TypeParameters.Length != 0 || !IsPartial(module)) - { - return Invalid(ModuleDiagnosticDescriptors.InvalidModuleShape, location); - } - - if (!TryDetermineConstructorEmission(module, out bool emitsPublicParameterlessConstructor, - out IMethodSymbol? nonPublicConstructor)) - { - return Invalid(ModuleDiagnosticDescriptors.NoPublicConstructor, - nonPublicConstructor?.Locations.FirstOrDefault() ?? location, - module.Name, - nonPublicConstructor is null - ? "non-public" - : nonPublicConstructor.DeclaredAccessibility.ToString()); - } - - AttributeData attribute = context.Attributes[0]; - if (attribute.ConstructorArguments.Length == 0 || - attribute.ConstructorArguments[0].Value is not INamedTypeSymbol bindings || !bindings.IsStatic || - bindings.TypeKind != TypeKind.Class || bindings.IsFileLocal || - bindings.OriginalDefinition.TypeParameters.Length != 0) - { - return Invalid(ModuleDiagnosticDescriptors.InvalidBindingsType, location); - } - - ImmutableArray exports = GetExports(bindings, out string? duplicateOrInvalidExport); - if (duplicateOrInvalidExport is not null) - { - return Invalid(ModuleDiagnosticDescriptors.InvalidExportSet, location, duplicateOrInvalidExport); - } - - if (exports.IsEmpty) - { - return Invalid(ModuleDiagnosticDescriptors.NoExports, location, bindings.Name); - } - - string? configuredName = attribute.ConstructorArguments.Length > 1 - ? attribute.ConstructorArguments[1].Value as string - : null; - string moduleName = configuredName ?? module.Name; - if (string.IsNullOrWhiteSpace(moduleName)) - { - return Invalid(ModuleDiagnosticDescriptors.InvalidModuleName, location); - } - - return new ModuleCandidate( - null, - module.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - .Replace("global::", string.Empty) - .Replace('.', '_') + ".CheatEngineLuaModule.g.cs", - module.ContainingNamespace.IsGlobalNamespace - ? string.Empty - : module.ContainingNamespace.ToDisplayString(), - TypeDeclaration(module, false), - EscapeIdentifier(module.Name), - TypeName(bindings), - moduleName, - exports, - emitsPublicParameterlessConstructor); - } - - private static ModuleCandidate Invalid(DiagnosticDescriptor descriptor, Location location, - params object[] arguments) - { - return new ModuleCandidate(Diagnostic.Create(descriptor, location, arguments), string.Empty, string.Empty, - string.Empty, string.Empty, string.Empty, string.Empty, ImmutableArray.Empty, false); - } - - private static bool TryDetermineConstructorEmission(INamedTypeSymbol module, - out bool emitsPublicParameterlessConstructor, out IMethodSymbol? nonPublicConstructor) - { - emitsPublicParameterlessConstructor = true; - nonPublicConstructor = null; - foreach (IMethodSymbol constructor in module.InstanceConstructors) - { - if (constructor.IsImplicitlyDeclared) - { - continue; - } - - if (constructor.DeclaredAccessibility == Accessibility.Public) - { - emitsPublicParameterlessConstructor = false; - return true; - } - - nonPublicConstructor = constructor; - } - - return nonPublicConstructor is null; - } - } - - private sealed class OperationCandidate - { - private OperationCandidate(Diagnostic? diagnostic, string hintName, string @namespace, - string containingTypeDeclaration, string operationVisibility, string bindingsType, string methodName, - string operationTypeName, ImmutableArray parameters, string sourceResultType, - string resultType, string? mapperType, bool hasOutResult, string failureOperation) - { - Diagnostic = diagnostic; - HintName = hintName; - Namespace = @namespace; - ContainingTypeDeclaration = containingTypeDeclaration; - OperationVisibility = operationVisibility; - BindingsType = bindingsType; - MethodName = methodName; - OperationTypeName = operationTypeName; - Parameters = parameters; - SourceResultType = sourceResultType; - ResultType = resultType; - MapperType = mapperType; - HasOutResult = hasOutResult; - FailureOperation = failureOperation; - } - - public Diagnostic? Diagnostic - { - get; - } - - public string HintName - { - get; - } - - public string Namespace - { - get; - } - - public string ContainingTypeDeclaration - { - get; - } - - public string OperationVisibility - { - get; - } - - public string BindingsType - { - get; - } - - public string MethodName - { - get; - } - - public string OperationTypeName - { - get; - } - - public ImmutableArray Parameters - { - get; - } - - public string SourceResultType - { - get; - } - - public string ResultType - { - get; - } - - public string? MapperType - { - get; - } - - public bool HasOutResult - { - get; - } - - public string FailureOperation - { - get; - } - - public static OperationCandidate Create(GeneratorAttributeSyntaxContext context) - { - IMethodSymbol? method = context.TargetSymbol as IMethodSymbol; - Location location = context.TargetNode.GetLocation(); - if (method is null || !HasAttribute(method, LuaGlobalAttributeMetadataName) || - method.MethodKind != MethodKind.Ordinary || - !method.IsStatic || method.IsGenericMethod || !method.IsPartialDefinition || - method.PartialImplementationPart is not null || method.ReturnsByRef || method.ReturnsByRefReadonly || - method.ContainingType.ContainingType is not null || method.ContainingType.IsFileLocal || - method.ContainingType.OriginalDefinition.TypeParameters.Length != 0 || - !method.ContainingType.IsStatic || !IsPartial(method.ContainingType)) - { - return Invalid(OperationDiagnosticDescriptors.InvalidOperationShape, location); - } - - if (method.ContainingType.GetMembers(method.Name).OfType() - .Count(static candidate => HasAttribute(candidate, LuaOperationAttributeMetadataName)) != 1) - { - return Invalid(OperationDiagnosticDescriptors.OverloadedOperation, location, method.Name); - } - - ImmutableArray.Builder parameters = ImmutableArray.CreateBuilder(); - IParameterSymbol? outResult = null; - foreach (IParameterSymbol parameter in method.Parameters) - { - if (parameter.RefKind == RefKind.Out) - { - if (outResult is not null || parameter.Ordinal != method.Parameters.Length - 1 || - parameter.IsParams || - parameter.HasExplicitDefaultValue) - { - return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); - } - - outResult = parameter; - continue; - } - - if (parameter.RefKind != RefKind.None || parameter.IsParams || parameter.HasExplicitDefaultValue || - !IsScalar(parameter.Type)) - { - return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); - } - - parameters.Add(new OperationParameter(TypeName(parameter.Type), EscapeIdentifier(parameter.Name))); - } - - if (outResult is not null && method.ReturnType.SpecialType != SpecialType.System_Boolean) - { - return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); - } - - if (outResult is null && method.ReturnsVoid) - { - return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); - } - - ITypeSymbol sourceResult = outResult?.Type ?? method.ReturnType; - AttributeData operationAttribute = context.Attributes[0]; - INamedTypeSymbol? mapper = operationAttribute.ConstructorArguments.Length == 1 - ? operationAttribute.ConstructorArguments[0].Value as INamedTypeSymbol - : null; - ITypeSymbol result = sourceResult; - if (mapper is null) - { - if (!IsScalar(sourceResult)) - { - return Invalid(OperationDiagnosticDescriptors.MapperRequired, location, method.Name); - } - } - else if (!TryGetMapperResult(mapper, sourceResult, out result)) - { - return Invalid(OperationDiagnosticDescriptors.InvalidMapper, location, mapper.Name, method.Name); - } - else if (TryFindClientBoundaryViolation(sourceResult, ClientBoundaryRole.MapperSource, - out string sourceViolation)) - { - return Invalid(OperationDiagnosticDescriptors.UnsafeMappedType, location, method.Name, - sourceViolation); - } - else if (TryFindClientBoundaryViolation(result, ClientBoundaryRole.ClientResult, - out string resultViolation)) - { - return Invalid(OperationDiagnosticDescriptors.UnsafeMappedType, location, method.Name, - resultViolation); - } - - string operationTypeName = method.Name + "LuaOperation"; - return new OperationCandidate( - null, - method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - .Replace("global::", string.Empty) - .Replace('.', '_') + "_" + method.Name + ".CheatEngineLuaOperation.g.cs", - method.ContainingNamespace.IsGlobalNamespace - ? string.Empty - : method.ContainingNamespace.ToDisplayString(), - TypeDeclaration(method.ContainingType, true), - method.ContainingType.DeclaredAccessibility == Accessibility.Public ? "public" : "internal", - TypeName(method.ContainingType), - method.Name, - operationTypeName, - parameters.ToImmutable(), - TypeName(sourceResult), - TypeName(result), - mapper is null ? null : TypeName(mapper), - outResult is not null, - "Lua.Operation." + method.ContainingType.Name + "." + method.Name); - } - - private static OperationCandidate Invalid(DiagnosticDescriptor descriptor, Location location, - params object[] arguments) - { - return new OperationCandidate(Diagnostic.Create(descriptor, location, arguments), string.Empty, - string.Empty, - string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, - ImmutableArray.Empty, - string.Empty, string.Empty, null, false, string.Empty); - } - } - - private readonly struct OperationParameter - { - public OperationParameter(string type, string name) - { - Type = type; - Name = name; - FieldName = "_" + name.TrimStart('@'); - } - - public string Type - { - get; - } - - public string Name - { - get; - } - - public string FieldName - { - get; - } - } - - private static class ModuleDiagnosticDescriptors - { - public static readonly DiagnosticDescriptor InvalidModuleShape = new( - "CECLUA1001", "Lua module must be a non-static partial class", - "[CheatEngineLuaModule] requires a top-level, concrete, non-static, non-generic, non-file-local partial class", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor InvalidBindingsType = new( - "CECLUA1002", "Lua module requires a static SDK bindings type", - "The bindings type for [CheatEngineLuaModule] must be a non-generic, non-file-local static class that owns SDK-generated registration methods", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor NoExports = new( - "CECLUA1003", "Lua module bindings export nothing", - "The bindings type '{0}' does not declare a [LuaFunction] export", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor InvalidExportSet = new( - "CECLUA1004", "Lua module exports must have unique names", - "The Lua module bindings contain an invalid or duplicate export name: '{0}'", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor InvalidModuleName = new( - "CECLUA1005", "Lua module name cannot be blank", - "The explicit [CheatEngineLuaModule] name cannot be empty or whitespace", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor NoPublicConstructor = new( - "CECLUA1006", "Lua module needs a public constructor", - "[CheatEngineLuaModule] cannot provide a safe public constructor because '{0}' declares constructors with no public accessibility; expose a public DI constructor or remove the explicit constructors so the generator can provide one", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - } - - private static class OperationDiagnosticDescriptors - { - public static readonly DiagnosticDescriptor InvalidOperationShape = new( - "CECLUA1101", "Lua operation requires a supported SDK global declaration", - "[CheatEngineLuaOperation] requires a static partial [LuaGlobal] method in a top-level, non-generic, non-file-local static partial class", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor OverloadedOperation = new( - "CECLUA1102", "Lua operation method cannot be overloaded", - "Lua operation '{0}' is overloaded; use distinct method names so generated operation factories remain unambiguous", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor UnsupportedSignature = new( - "CECLUA1103", "Lua operation has an unsupported result shape", - "Lua operation '{0}' must have scalar input arguments and exactly one return value or one trailing out result", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor MapperRequired = new( - "CECLUA1104", "Lua operation result requires a mapper", - "Lua operation '{0}' returns a non-scalar SDK value; declare [CheatEngineLuaOperation(typeof(TMapper))] with an ILuaResultMapper implementation", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor InvalidMapper = new( - "CECLUA1105", "Lua operation mapper does not match the SDK result", - "Mapper '{0}' does not implement ILuaResultMapper for the result of Lua operation '{1}'", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - - public static readonly DiagnosticDescriptor UnsafeMappedType = new( - "CECLUA1106", "Lua operation mapper must project a safe Client result", - "Lua operation '{0}' maps an unsafe value across the Client boundary: {1}", - "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); - } - - private sealed class SourceBuilder - { - private readonly StringBuilder _source = new(); - private int _indent; - - public void WriteGeneratedHeader() - { - WriteLine("// "); - WriteLine("#nullable enable"); - WriteLine(); - } - - public void OpenNamespace(string @namespace) - { - if (string.IsNullOrEmpty(@namespace)) - { - return; - } - - WriteLine("namespace " + @namespace + ";"); - WriteLine(); - } - - public void OpenBlock() - { - WriteLine("{"); - Indent(); - } - - public void CloseBlock() - { - Unindent(); - WriteLine("}"); - } - - public void WriteLine(string value = "") - { - if (value.Length != 0) - { - _source.Append('\t', _indent); - } - - _source.AppendLine(value); - } - - public void Indent() - { - _indent++; - } - - public void Unindent() - { - _indent--; - } - - public override string ToString() - { - return _source.ToString(); - } - } } diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/DiagnosticInfo.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/DiagnosticInfo.cs new file mode 100644 index 0000000..3f414f1 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/DiagnosticInfo.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.Client.SourceGenerators.Lua.Model; + +/// +/// A value description of a generator diagnostic: descriptor id, copied location, and string message arguments. It is +/// turned into a only when the output is produced, so pipeline models root no compilation. +/// +internal sealed class DiagnosticInfo : IEquatable +{ + private DiagnosticInfo(string id, LocationInfo? location, EquatableArray arguments) + { + Id = id; + Location = location; + Arguments = arguments; + } + + public string Id + { + get; + } + + public LocationInfo? Location + { + get; + } + + public EquatableArray Arguments + { + get; + } + + public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, LocationInfo? location, + params string[] arguments) + { + return new DiagnosticInfo(descriptor.Id, location, EquatableArray.Create(ImmutableArray.Create(arguments))); + } + + public Diagnostic ToDiagnostic() + { + object[] arguments = new object[Arguments.Length]; + for (int index = 0; index < arguments.Length; index++) + { + arguments[index] = Arguments[index]; + } + + return Diagnostic.Create(CheatEngineLuaDiagnostics.Get(Id), + Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None, arguments); + } + + public bool Equals(DiagnosticInfo? other) + { + return other is not null && string.Equals(Id, other.Id, StringComparison.Ordinal) && + Equals(Location, other.Location) && Arguments.Equals(other.Arguments); + } + + public override bool Equals(object? obj) + { + return Equals(obj as DiagnosticInfo); + } + + public override int GetHashCode() + { + unchecked + { + return (StringComparer.Ordinal.GetHashCode(Id) * 31) + Arguments.GetHashCode(); + } + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/EquatableArray.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/EquatableArray.cs new file mode 100644 index 0000000..2d3a671 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/EquatableArray.cs @@ -0,0 +1,110 @@ +using System.Collections; +using System.Collections.Immutable; + +namespace CheatEngine.Client.SourceGenerators.Lua.Model; + +/// +/// An immutable array with ordinal, element-wise value equality, so incremental pipeline models that contain sequences +/// compare equal when their content is equal. +/// +/// The element type; its own equality defines the sequence equality. +/// +/// compares by reference, which makes every re-parsed model look modified and defeats +/// generator caching (https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md). +/// +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly T[]? _items; + + public EquatableArray(ImmutableArray items) + { + _items = items.IsDefaultOrEmpty ? null : items.ToArray(); + } + + public static EquatableArray Empty => default; + + public int Length => _items?.Length ?? 0; + + public bool IsEmpty => Length == 0; + + public T this[int index] => (_items ?? throw new ArgumentOutOfRangeException(nameof(index)))[index]; + + public static bool operator ==(EquatableArray left, EquatableArray right) + { + return left.Equals(right); + } + + public static bool operator !=(EquatableArray left, EquatableArray right) + { + return !left.Equals(right); + } + + public bool Equals(EquatableArray other) + { + int length = Length; + if (length != other.Length) + { + return false; + } + + for (int index = 0; index < length; index++) + { + if (!EqualityComparer.Default.Equals(_items![index], other._items![index])) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) + { + return obj is EquatableArray other && Equals(other); + } + + public override int GetHashCode() + { + // netstandard2.0 has no System.HashCode: combine with the classic multiplicative scheme. + unchecked + { + int hash = 17; + if (_items is not null) + { + foreach (T item in _items) + { + hash = (hash * 31) + (item is null ? 0 : EqualityComparer.Default.GetHashCode(item)); + } + } + + return hash; + } + } + + public IEnumerator GetEnumerator() + { + return ((IEnumerable) (_items ?? [])).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} + +/// Factory helpers for . +internal static class EquatableArray +{ + public static EquatableArray Create(ImmutableArray items) + where T : IEquatable + { + return new EquatableArray(items); + } + + public static EquatableArray Create(params T[] items) + where T : IEquatable + { + return new EquatableArray(ImmutableArray.Create(items)); + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/LocationInfo.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/LocationInfo.cs new file mode 100644 index 0000000..199624f --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/LocationInfo.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace CheatEngine.Client.SourceGenerators.Lua.Model; + +/// +/// A value copy of a source location: file path and spans only, never the syntax tree a roots. +/// +/// +/// Converted back with when a diagnostic is reported +/// (https://learn.microsoft.com/dotnet/api/microsoft.codeanalysis.location.create). +/// +internal sealed class LocationInfo : IEquatable +{ + public LocationInfo(string filePath, TextSpan textSpan, LinePositionSpan lineSpan) + { + FilePath = filePath; + TextSpan = textSpan; + LineSpan = lineSpan; + } + + public string FilePath + { + get; + } + + public TextSpan TextSpan + { + get; + } + + public LinePositionSpan LineSpan + { + get; + } + + public static LocationInfo? From(Location? location) + { + if (location is null || !location.IsInSource) + { + return null; + } + + FileLinePositionSpan lineSpan = location.GetLineSpan(); + return new LocationInfo(lineSpan.Path ?? string.Empty, location.SourceSpan, lineSpan.Span); + } + + public static LocationInfo? From(SyntaxNode? node) + { + return node is null ? null : From(node.GetLocation()); + } + + public Location ToLocation() + { + return Location.Create(FilePath, TextSpan, LineSpan); + } + + public bool Equals(LocationInfo? other) + { + return other is not null && string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) && + TextSpan.Equals(other.TextSpan) && LineSpan.Equals(other.LineSpan); + } + + public override bool Equals(object? obj) + { + return Equals(obj as LocationInfo); + } + + public override int GetHashCode() + { + unchecked + { + return (StringComparer.Ordinal.GetHashCode(FilePath) * 31) + TextSpan.GetHashCode(); + } + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/ModuleModel.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/ModuleModel.cs new file mode 100644 index 0000000..1b143fa --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/ModuleModel.cs @@ -0,0 +1,122 @@ +namespace CheatEngine.Client.SourceGenerators.Lua.Model; + +/// +/// The equatable, Roslyn-free description of one [CheatEngineLuaModule] declaration: either the data the module +/// emitter needs or the diagnostics that stop generation. +/// +internal sealed class ModuleModel : IEquatable +{ + public ModuleModel(EquatableArray diagnostics, LocationInfo? location, string moduleDisplayName, + string hintName, string @namespace, string typeDeclaration, string moduleTypeName, string bindingsType, + string moduleName, EquatableArray exports, bool emitsPublicParameterlessConstructor) + { + Diagnostics = diagnostics; + Location = location; + ModuleDisplayName = moduleDisplayName; + HintName = hintName; + Namespace = @namespace; + TypeDeclaration = typeDeclaration; + ModuleTypeName = moduleTypeName; + BindingsType = bindingsType; + ModuleName = moduleName; + Exports = exports; + EmitsPublicParameterlessConstructor = emitsPublicParameterlessConstructor; + } + + /// Gets the diagnostics that stop generation; empty for a valid module. + public EquatableArray Diagnostics + { + get; + } + + /// Gets the location of the attributed declaration (cross-module diagnostics and their ordering). + public LocationInfo? Location + { + get; + } + + /// Gets the module type's fully qualified display name without the global:: alias. + public string ModuleDisplayName + { + get; + } + + public string HintName + { + get; + } + + public string Namespace + { + get; + } + + public string TypeDeclaration + { + get; + } + + public string ModuleTypeName + { + get; + } + + public string BindingsType + { + get; + } + + public string ModuleName + { + get; + } + + /// Gets the exported Lua global names in declaration order (the descriptor and release order). + public EquatableArray Exports + { + get; + } + + public bool EmitsPublicParameterlessConstructor + { + get; + } + + public bool IsValid => Diagnostics.IsEmpty; + + public static ModuleModel Invalid(LocationInfo? location, string moduleDisplayName, + params DiagnosticInfo[] diagnostics) + { + return new ModuleModel(EquatableArray.Create(diagnostics), location, moduleDisplayName, string.Empty, + string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, EquatableArray.Empty, false); + } + + public bool Equals(ModuleModel? other) + { + return other is not null && Diagnostics.Equals(other.Diagnostics) && Equals(Location, other.Location) && + string.Equals(ModuleDisplayName, other.ModuleDisplayName, StringComparison.Ordinal) && + string.Equals(HintName, other.HintName, StringComparison.Ordinal) && + string.Equals(Namespace, other.Namespace, StringComparison.Ordinal) && + string.Equals(TypeDeclaration, other.TypeDeclaration, StringComparison.Ordinal) && + string.Equals(ModuleTypeName, other.ModuleTypeName, StringComparison.Ordinal) && + string.Equals(BindingsType, other.BindingsType, StringComparison.Ordinal) && + string.Equals(ModuleName, other.ModuleName, StringComparison.Ordinal) && + Exports.Equals(other.Exports) && + EmitsPublicParameterlessConstructor == other.EmitsPublicParameterlessConstructor; + } + + public override bool Equals(object? obj) + { + return Equals(obj as ModuleModel); + } + + public override int GetHashCode() + { + unchecked + { + int hash = StringComparer.Ordinal.GetHashCode(ModuleDisplayName); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ModuleName); + return (hash * 31) + Exports.GetHashCode(); + } + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/OperationModel.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/OperationModel.cs new file mode 100644 index 0000000..58f89b0 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/Model/OperationModel.cs @@ -0,0 +1,188 @@ +namespace CheatEngine.Client.SourceGenerators.Lua.Model; + +/// +/// The equatable, Roslyn-free description of one [CheatEngineLuaOperation] declaration: either the data the +/// operation emitter needs or the diagnostics that stop generation. +/// +internal sealed class OperationModel : IEquatable +{ + public OperationModel(EquatableArray diagnostics, string hintName, string @namespace, + string containingTypeDeclaration, string operationVisibility, string bindingsType, string methodName, + string operationTypeName, EquatableArray parameters, string sourceResultType, + string resultType, string? mapperType, bool hasOutResult, string failureOperation) + { + Diagnostics = diagnostics; + HintName = hintName; + Namespace = @namespace; + ContainingTypeDeclaration = containingTypeDeclaration; + OperationVisibility = operationVisibility; + BindingsType = bindingsType; + MethodName = methodName; + OperationTypeName = operationTypeName; + Parameters = parameters; + SourceResultType = sourceResultType; + ResultType = resultType; + MapperType = mapperType; + HasOutResult = hasOutResult; + FailureOperation = failureOperation; + } + + /// Gets the diagnostics that stop generation; empty for a valid operation. + public EquatableArray Diagnostics + { + get; + } + + public string HintName + { + get; + } + + public string Namespace + { + get; + } + + public string ContainingTypeDeclaration + { + get; + } + + public string OperationVisibility + { + get; + } + + public string BindingsType + { + get; + } + + public string MethodName + { + get; + } + + public string OperationTypeName + { + get; + } + + public EquatableArray Parameters + { + get; + } + + public string SourceResultType + { + get; + } + + public string ResultType + { + get; + } + + public string? MapperType + { + get; + } + + public bool HasOutResult + { + get; + } + + public string FailureOperation + { + get; + } + + public bool IsValid => Diagnostics.IsEmpty; + + public static OperationModel Invalid(DiagnosticInfo diagnostic) + { + return new OperationModel(EquatableArray.Create(diagnostic), string.Empty, string.Empty, string.Empty, + string.Empty, string.Empty, string.Empty, string.Empty, EquatableArray.Empty, + string.Empty, string.Empty, null, false, string.Empty); + } + + public bool Equals(OperationModel? other) + { + return other is not null && Diagnostics.Equals(other.Diagnostics) && + string.Equals(HintName, other.HintName, StringComparison.Ordinal) && + string.Equals(Namespace, other.Namespace, StringComparison.Ordinal) && + string.Equals(ContainingTypeDeclaration, other.ContainingTypeDeclaration, StringComparison.Ordinal) && + string.Equals(OperationVisibility, other.OperationVisibility, StringComparison.Ordinal) && + string.Equals(BindingsType, other.BindingsType, StringComparison.Ordinal) && + string.Equals(MethodName, other.MethodName, StringComparison.Ordinal) && + string.Equals(OperationTypeName, other.OperationTypeName, StringComparison.Ordinal) && + Parameters.Equals(other.Parameters) && + string.Equals(SourceResultType, other.SourceResultType, StringComparison.Ordinal) && + string.Equals(ResultType, other.ResultType, StringComparison.Ordinal) && + string.Equals(MapperType, other.MapperType, StringComparison.Ordinal) && + HasOutResult == other.HasOutResult && + string.Equals(FailureOperation, other.FailureOperation, StringComparison.Ordinal); + } + + public override bool Equals(object? obj) + { + return Equals(obj as OperationModel); + } + + public override int GetHashCode() + { + unchecked + { + int hash = StringComparer.Ordinal.GetHashCode(HintName); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ResultType); + return (hash * 31) + Parameters.GetHashCode(); + } + } +} + +/// One scalar input parameter of a generated Lua operation. +internal readonly struct OperationParameter : IEquatable +{ + public OperationParameter(string type, string name) + { + Type = type; + Name = name; + FieldName = "_" + name.TrimStart('@'); + } + + public string Type + { + get; + } + + public string Name + { + get; + } + + public string FieldName + { + get; + } + + public bool Equals(OperationParameter other) + { + return string.Equals(Type, other.Type, StringComparison.Ordinal) && + string.Equals(Name, other.Name, StringComparison.Ordinal); + } + + public override bool Equals(object? obj) + { + return obj is OperationParameter other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return (StringComparer.Ordinal.GetHashCode(Type ?? string.Empty) * 31) + + StringComparer.Ordinal.GetHashCode(Name ?? string.Empty); + } + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/ModuleEmitter.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/ModuleEmitter.cs new file mode 100644 index 0000000..6c8d4e8 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/ModuleEmitter.cs @@ -0,0 +1,118 @@ +using System.Text; + +using CheatEngine.Client.SourceGenerators.Lua.Model; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// Emits the generated partial part of one valid [CheatEngineLuaModule] declaration. +/// +/// +/// The module implements ILuaModule with three members and one field. Register hands the +/// SDK-generated TryRegisterLuaFunctions of the bindings type, with the RejectExisting collision +/// policy, to the assembly's registrar, which admits the Lua work, publishes, and +/// keeps the CheatEngine.SDK registration lease in the field. Unregister asks the registrar to release that +/// lease and returns what CheatEngine.SDK observed. The module itself makes no CheatEngine.SDK call: the only SDK +/// member it names is the bindings type's registration method, which the registrar invokes. +/// +/// +/// The legacy SDK RegisterLuaFunctions/UnregisterLuaFunctions pair, which writes unconditionally, is +/// never called (F12, Q16). +/// +/// +internal static class ModuleEmitter +{ + /// The field that holds the registration lease; CECLUA1202 reserves its name in module declarations. + internal const string RegistrationField = "_luaRegistration"; + + private const string Registrar = "global::" + RegistrarEmitter.Namespace + "." + RegistrarEmitter.RegistrarType; + + public static string Emit(ModuleModel model) + { + SourceBuilder source = new(); + source.WriteGeneratedHeader(); + source.OpenNamespace(model.Namespace); + source.WriteLine(model.TypeDeclaration + " : global::CheatEngine.Client.Lua.ILuaModule"); + source.OpenBlock(); + if (model.EmitsPublicParameterlessConstructor) + { + source.WriteLine( + "/// Initializes a Lua module instance for activation-scoped dependency injection."); + source.WriteLine("public " + model.ModuleTypeName + "()"); + source.OpenBlock(); + source.CloseBlock(); + source.WriteLine(); + } + + EmitDescriptor(source, model); + EmitMembers(source, model); + source.CloseBlock(); + + return source.ToString(); + } + + private static void EmitDescriptor(SourceBuilder source, ModuleModel model) + { + source.WriteLine("private static readonly global::CheatEngine.Client.Lua.LuaModuleDescriptor s_descriptor ="); + source.Indent(); + source.WriteLine("new global::CheatEngine.Client.Lua.LuaModuleDescriptor("); + source.Indent(); + source.WriteLine(CheatEngineLuaGenerator.CSharpLiteral(model.ModuleName) + ","); + source.WriteLine(EmitExports(model.Exports) + ");"); + source.Unindent(); + source.Unindent(); + source.WriteLine(); + source.WriteLine( + "// The CheatEngine.SDK registration lease this module owns while it is registered; null otherwise. Main thread only."); + source.WriteLine("private object? " + RegistrationField + ";"); + source.WriteLine(); + source.WriteLine("/// "); + source.WriteLine("public global::CheatEngine.Client.Lua.LuaModuleDescriptor Descriptor => s_descriptor;"); + source.WriteLine(); + } + + private static void EmitMembers(SourceBuilder source, ModuleModel model) + { + source.WriteLine("/// "); + source.WriteLine("public void Register()"); + source.OpenBlock(); + source.WriteLine(Registrar + ".Register(s_descriptor, ref " + RegistrationField + ","); + source.Indent(); + source.WriteLine("static state => " + model.BindingsType + ".TryRegisterLuaFunctions(state,"); + source.Indent(); + source.WriteLine("global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting));"); + source.Unindent(); + source.Unindent(); + source.CloseBlock(); + source.WriteLine(); + source.WriteLine("/// "); + source.WriteLine("public global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome Unregister()"); + source.OpenBlock(); + source.WriteLine("return " + Registrar + ".Unregister(s_descriptor, ref " + RegistrationField + ");"); + source.CloseBlock(); + } + + private static string EmitExports(EquatableArray exports) + { + if (exports.IsEmpty) + { + return + "global::System.Collections.Immutable.ImmutableArray.Empty"; + } + + StringBuilder source = new("global::System.Collections.Immutable.ImmutableArray.Create("); + for (int index = 0; index < exports.Length; index++) + { + if (index != 0) + { + source.Append(", "); + } + + source.Append("new global::CheatEngine.Client.Lua.LuaExportDescriptor("); + source.Append(CheatEngineLuaGenerator.CSharpLiteral(exports[index])); + source.Append(')'); + } + + source.Append(')'); + return source.ToString(); + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/ModuleParser.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/ModuleParser.cs new file mode 100644 index 0000000..1df3281 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/ModuleParser.cs @@ -0,0 +1,335 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.SourceGenerators.Lua.Model; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// Turns one [CheatEngineLuaModule] declaration into an equatable . +internal static class ModuleParser +{ + internal const string ContractAssemblyName = "CheatEngine.Client.Abstractions"; + internal const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; + + private const string LuaModuleInterfaceMetadataName = "CheatEngine.Client.Lua.ILuaModule"; + + /// The names of the members the generated module part declares; CECLUA1202 reserves them in the module. + internal static readonly ImmutableArray ReservedMemberNames = + [ + "Register", "Unregister", "Descriptor", "s_descriptor", ModuleEmitter.RegistrationField, "get_Descriptor" + ]; + + private static readonly ImmutableArray ModuleContractInterfaces = [LuaModuleInterfaceMetadataName]; + + public static ModuleModel Parse(GeneratorAttributeSyntaxContext context) + { + INamedTypeSymbol? module = context.TargetSymbol as INamedTypeSymbol; + LocationInfo? location = LocationInfo.From(context.TargetNode); + string displayName = module is null ? string.Empty : DisplayName(module); + AttributeData attribute = context.Attributes[0]; + if (!IsDeclaredIn(attribute.AttributeClass, ContractAssemblyName)) + { + return ModuleModel.Invalid(location, displayName, + LookAlike(attribute, ContractAssemblyName, location)); + } + + if (module is null || module.TypeKind != TypeKind.Class || module.IsStatic || module.IsAbstract || + module.IsFileLocal || module.ContainingType is not null || + module.OriginalDefinition.TypeParameters.Length != 0 || !CheatEngineLuaGenerator.IsPartial(module)) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.InvalidModuleShape, location)); + } + + INamedTypeSymbol? inheritedModule = FindInheritedModuleImplementation(module); + if (inheritedModule is not null) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.InheritedModuleImplementation, + BaseListLocation(context, module) ?? location, displayName, DisplayName(inheritedModule))); + } + + DiagnosticInfo[] reserved = FindReservedMembers(module, displayName); + if (reserved.Length != 0) + { + return ModuleModel.Invalid(location, displayName, reserved); + } + + if (!TryDetermineConstructorEmission(module, out bool emitsPublicParameterlessConstructor, + out IMethodSymbol? nonPublicConstructor)) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.NoPublicConstructor, + LocationInfo.From(nonPublicConstructor?.Locations.FirstOrDefault()) ?? location, + module.Name, + nonPublicConstructor is null + ? "non-public" + : nonPublicConstructor.DeclaredAccessibility.ToString())); + } + + if (attribute.ConstructorArguments.Length == 0 || + attribute.ConstructorArguments[0].Value is not INamedTypeSymbol bindings || !bindings.IsStatic || + bindings.TypeKind != TypeKind.Class || bindings.IsFileLocal || + bindings.OriginalDefinition.TypeParameters.Length != 0) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.InvalidBindingsType, location)); + } + + ImmutableArray exports = GetExports(bindings, location, out string? duplicateOrInvalidExport, + out DiagnosticInfo[] lookAlikes); + if (lookAlikes.Length != 0) + { + return ModuleModel.Invalid(location, displayName, lookAlikes); + } + + if (duplicateOrInvalidExport is not null) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.InvalidExportSet, location, duplicateOrInvalidExport)); + } + + if (exports.IsEmpty) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.NoExports, location, bindings.Name)); + } + + string? configuredName = attribute.ConstructorArguments.Length > 1 + ? attribute.ConstructorArguments[1].Value as string + : null; + string moduleName = configuredName ?? module.Name; + if (string.IsNullOrWhiteSpace(moduleName)) + { + return ModuleModel.Invalid(location, displayName, + DiagnosticInfo.Create(CheatEngineLuaDiagnostics.InvalidModuleName, location)); + } + + return new ModuleModel( + EquatableArray.Empty, + location, + displayName, + CheatEngineLuaGenerator.HintNameStem(module) + ".CheatEngineLuaModule.g.cs", + module.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : module.ContainingNamespace.ToDisplayString(), + CheatEngineLuaGenerator.TypeDeclaration(module, false), + CheatEngineLuaGenerator.EscapeIdentifier(module.Name), + CheatEngineLuaGenerator.TypeName(bindings), + moduleName, + EquatableArray.Create(exports), + emitsPublicParameterlessConstructor); + } + + private static string DisplayName(INamedTypeSymbol type) + { + return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", string.Empty); + } + + /// + /// Assembly identity check that rejects look-alike annotation types: the same metadata name declared in user code + /// (or in any other assembly) is not the contract (DoD D.7). + /// + private static bool IsDeclaredIn(INamedTypeSymbol? type, string assemblyName) + { + return type?.ContainingAssembly is { } assembly && + string.Equals(assembly.Name, assemblyName, StringComparison.Ordinal); + } + + private static DiagnosticInfo LookAlike(AttributeData attribute, string expectedAssembly, LocationInfo? fallback) + { + return DiagnosticInfo.Create(CheatEngineLuaDiagnostics.LookAlikeAnnotation, + LocationInfo.From(attribute.ApplicationSyntaxReference?.GetSyntax()) ?? fallback, + attribute.AttributeClass?.ToDisplayString() ?? "(unresolved attribute)", + attribute.AttributeClass?.ContainingAssembly?.Name ?? "(unknown)", + expectedAssembly); + } + + private static INamedTypeSymbol? FindInheritedModuleImplementation(INamedTypeSymbol module) + { + for (INamedTypeSymbol? baseType = module.BaseType; + baseType is not null && baseType.SpecialType != SpecialType.System_Object; + baseType = baseType.BaseType) + { + // The base's own generated adapter is invisible to this run, so a generated base module is recognized by its + // contract attribute as well as by an implemented ILuaModule. + if (ImplementsLuaModuleContract(baseType) || HasContractModuleAttribute(baseType)) + { + return baseType; + } + } + + return null; + } + + private static bool ImplementsLuaModuleContract(INamedTypeSymbol type) + { + foreach (INamedTypeSymbol candidate in type.AllInterfaces) + { + if (string.Equals(candidate.ToDisplayString(), LuaModuleInterfaceMetadataName, StringComparison.Ordinal) && + IsDeclaredIn(candidate, ContractAssemblyName)) + { + return true; + } + } + + return false; + } + + private static bool HasContractModuleAttribute(INamedTypeSymbol type) + { + foreach (AttributeData attribute in type.GetAttributes()) + { + if (string.Equals(attribute.AttributeClass?.ToDisplayString(), + CheatEngineLuaGenerator.LuaModuleAttributeMetadataName, StringComparison.Ordinal) && + IsDeclaredIn(attribute.AttributeClass, ContractAssemblyName)) + { + return true; + } + } + + return false; + } + + private static LocationInfo? BaseListLocation(GeneratorAttributeSyntaxContext context, INamedTypeSymbol module) + { + if (context.TargetNode is ClassDeclarationSyntax { BaseList: { } targetBaseList }) + { + return LocationInfo.From(targetBaseList); + } + + foreach (SyntaxReference reference in module.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is ClassDeclarationSyntax { BaseList: { } baseList }) + { + return LocationInfo.From(baseList); + } + } + + return null; + } + + private static DiagnosticInfo[] FindReservedMembers(INamedTypeSymbol module, string displayName) + { + List diagnostics = []; + foreach (ISymbol member in module.GetMembers()) + { + if (member.IsImplicitlyDeclared || member is IMethodSymbol + { + MethodKind: MethodKind.Constructor or MethodKind.StaticConstructor or MethodKind.PropertyGet or + MethodKind.PropertySet or MethodKind.EventAdd or MethodKind.EventRemove or MethodKind.EventRaise + }) + { + continue; + } + + string? reservedName = ReservedName(member); + if (reservedName is not null) + { + diagnostics.Add(DiagnosticInfo.Create(CheatEngineLuaDiagnostics.ReservedModuleMember, + LocationInfo.From(member.Locations.FirstOrDefault()), displayName, reservedName)); + } + } + + return [.. diagnostics]; + } + + private static string? ReservedName(ISymbol member) + { + ImmutableArray implemented = member switch + { + IMethodSymbol method => [.. method.ExplicitInterfaceImplementations], + IPropertySymbol property => [.. property.ExplicitInterfaceImplementations], + _ => ImmutableArray.Empty + }; + foreach (ISymbol target in implemented) + { + // An explicit implementation of the module contract would replace the generated one and bypass it. + if (target.ContainingType is { } contract && + ModuleContractInterfaces.Contains(contract.ToDisplayString()) && + IsDeclaredIn(contract, ContractAssemblyName)) + { + return contract.Name + "." + target.Name; + } + } + + string name = member.Name; + return ReservedMemberNames.Contains(name) ? name : null; + } + + private static ImmutableArray GetExports(INamedTypeSymbol bindings, LocationInfo? fallback, + out string? invalidExport, out DiagnosticInfo[] lookAlikes) + { + ImmutableArray.Builder exports = ImmutableArray.CreateBuilder(); + List lookAlikeDiagnostics = []; + HashSet names = new(StringComparer.Ordinal); + invalidExport = null; + foreach (IMethodSymbol method in bindings.GetMembers().OfType()) + { + AttributeData? attribute = CheatEngineLuaGenerator.GetAttribute(method, + CheatEngineLuaGenerator.LuaFunctionAttributeMetadataName); + if (attribute is null) + { + continue; + } + + if (!IsDeclaredIn(attribute.AttributeClass, AnnotationsAssemblyName)) + { + // A look-alike [LuaFunction] is never counted as an export. + lookAlikeDiagnostics.Add(LookAlike(attribute, AnnotationsAssemblyName, fallback)); + continue; + } + + if (invalidExport is not null) + { + continue; + } + + string? name = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as string + : null; + if (name is null) + { + invalidExport = "(missing name)"; + continue; + } + + if (string.IsNullOrWhiteSpace(name) || !names.Add(name)) + { + invalidExport = name; + continue; + } + + exports.Add(name); + } + + lookAlikes = [.. lookAlikeDiagnostics]; + return invalidExport is null ? exports.ToImmutable() : ImmutableArray.Empty; + } + + private static bool TryDetermineConstructorEmission(INamedTypeSymbol module, + out bool emitsPublicParameterlessConstructor, out IMethodSymbol? nonPublicConstructor) + { + emitsPublicParameterlessConstructor = true; + nonPublicConstructor = null; + foreach (IMethodSymbol constructor in module.InstanceConstructors) + { + if (constructor.IsImplicitlyDeclared) + { + continue; + } + + if (constructor.DeclaredAccessibility == Accessibility.Public) + { + emitsPublicParameterlessConstructor = false; + return true; + } + + nonPublicConstructor = constructor; + } + + return nonPublicConstructor is null; + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/OperationEmitter.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/OperationEmitter.cs new file mode 100644 index 0000000..1e26fb2 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/OperationEmitter.cs @@ -0,0 +1,210 @@ +using CheatEngine.Client.SourceGenerators.Lua.Model; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// +/// Emits the handle-free readonly value operation, its factory, and its inferable ILuaClient extension methods +/// for one [CheatEngineLuaOperation]. +/// +internal static class OperationEmitter +{ + private const string LuaClient = "global::CheatEngine.Client.Lua.ILuaClient"; + + private const string ResultsNamespace = "global::CheatEngine.Client.Results."; + + private const string CancellationTokenParameter = + "global::System.Threading.CancellationToken cancellationToken = default"; + + public static string Emit(OperationModel model) + { + SourceBuilder source = new(); + source.WriteGeneratedHeader(); + source.OpenNamespace(model.Namespace); + source.WriteLine(model.ContainingTypeDeclaration); + source.OpenBlock(); + source.WriteLine("/// Creates a handle-free Client operation for " + model.MethodName + + "."); + source.WriteLine("public static " + model.OperationTypeName + " Create" + model.OperationTypeName + "(" + + EmitParameterList(model.Parameters) + ")"); + source.OpenBlock(); + source.WriteLine("return new " + model.OperationTypeName + "(" + EmitArgumentList(model.Parameters, false) + + ");"); + source.CloseBlock(); + source.WriteLine(); + EmitClientExtensions(source, model); + source.WriteLine("/// Generated readonly value operation for " + model.MethodName + + "."); + source.WriteLine(model.OperationVisibility + " readonly record struct " + model.OperationTypeName + "(" + + EmitRecordParameterList(model.Parameters) + + ") : global::CheatEngine.Client.Lua.ILuaOperation<" + model.ResultType + ">"); + source.OpenBlock(); + source.WriteLine("/// "); + source.WriteLine("public bool TryExecute(global::CheatEngine.Client.Lua.ILuaExecutionContext context, out " + + model.ResultType + + " result, out global::CheatEngine.Client.Results.CheatEngineFailure failure)"); + source.OpenBlock(); + source.WriteLine("global::System.ArgumentNullException.ThrowIfNull(context);"); + source.WriteLine("context.ThrowIfExpired();"); + source.WriteLine(model.SourceResultType + " source;"); + source.WriteLine("try"); + source.OpenBlock(); + if (model.HasOutResult) + { + source.WriteLine("if (!" + model.BindingsType + "." + model.MethodName + "(" + + EmitOutArgumentList(model.Parameters) + "))"); + source.OpenBlock(); + source.WriteLine("result = default!;"); + source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); + source.Indent(); + source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError,"); + source.WriteLine(CheatEngineLuaGenerator.CSharpLiteral(model.FailureOperation) + ","); + source.WriteLine(CheatEngineLuaGenerator.CSharpLiteral( + "The SDK Lua Try binding returned false without exposing an unsafe Lua handle.") + ");"); + source.Unindent(); + source.WriteLine("return false;"); + source.CloseBlock(); + } + else + { + source.WriteLine("source = " + model.BindingsType + "." + model.MethodName + "(" + + EmitArgumentList(model.Parameters, true) + ");"); + } + + source.CloseBlock(); + source.WriteLine("catch (global::CheatEngine.SDK.Lua.Calls.LuaException exception)"); + source.OpenBlock(); + source.WriteLine("result = default!;"); + source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); + source.Indent(); + source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError,"); + source.WriteLine(CheatEngineLuaGenerator.CSharpLiteral(model.FailureOperation) + ","); + source.WriteLine("exception.Message,"); + source.WriteLine("exception);"); + source.Unindent(); + source.WriteLine("return false;"); + source.CloseBlock(); + source.WriteLine("catch (global::System.Exception exception)"); + source.OpenBlock(); + source.WriteLine("result = default!;"); + source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); + source.Indent(); + source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.BindingError,"); + source.WriteLine(CheatEngineLuaGenerator.CSharpLiteral(model.FailureOperation) + ","); + source.WriteLine("exception.Message,"); + source.WriteLine("exception);"); + source.Unindent(); + source.WriteLine("return false;"); + source.CloseBlock(); + source.WriteLine(); + // Only the SDK binding call is classified: the mapper is application code, whose exception leaves TryExecute + // unchanged so that the Client rethrows it, like an exception of any other application-supplied code. + source.WriteLine(model.MapperType is null + ? "result = source;" + : "result = " + model.MapperType + ".Map(source);"); + source.WriteLine("failure = default;"); + source.WriteLine("return true;"); + source.CloseBlock(); + source.CloseBlock(); + source.CloseBlock(); + + return source.ToString(); + } + + // The ILuaClient pair is generic over the operation and its result; these overloads infer both from the operation + // type, so a call reads client.Execute(operation), passes the operation by reference and never boxes it. + private static void EmitClientExtensions(SourceBuilder source, OperationModel model) + { + string typeArguments = "<" + model.OperationTypeName + ", " + model.ResultType + ">"; + source.WriteLine("/// Executes the generated " + model.MethodName + + " operation on Cheat Engine's main thread."); + source.WriteLine("/// The Lua client of the current activation."); + source.WriteLine("/// The operation to execute."); + source.WriteLine("/// Cancellation observed before dispatch admission."); + source.WriteLine("/// The copied result."); + EmitExtensionExceptions(source, model, true); + source.WriteLine("public static " + model.ResultType + " Execute(this " + LuaClient + " client, in " + + model.OperationTypeName + " operation,"); + source.Indent(); + source.WriteLine(CancellationTokenParameter + ")"); + source.Unindent(); + source.OpenBlock(); + source.WriteLine("global::System.ArgumentNullException.ThrowIfNull(client);"); + source.WriteLine("return client.Execute" + typeArguments + "(in operation, cancellationToken);"); + source.CloseBlock(); + source.WriteLine(); + source.WriteLine("/// Tries to execute the generated " + model.MethodName + + " operation on Cheat Engine's main thread."); + source.WriteLine("/// The Lua client of the current activation."); + source.WriteLine("/// The operation to execute."); + source.WriteLine("/// The copied result on success."); + source.WriteLine("/// The mapped Client failure on failure."); + source.WriteLine("/// Cancellation observed before dispatch admission."); + source.WriteLine("/// when the operation completed successfully."); + EmitExtensionExceptions(source, model, false); + source.WriteLine("public static bool TryExecute(this " + LuaClient + " client, in " + model.OperationTypeName + + " operation,"); + source.Indent(); + source.WriteLine("[global::System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out " + model.ResultType + + " result, out global::CheatEngine.Client.Results.CheatEngineFailure failure,"); + source.WriteLine(CancellationTokenParameter + ")"); + source.Unindent(); + source.OpenBlock(); + source.WriteLine("global::System.ArgumentNullException.ThrowIfNull(client);"); + source.WriteLine("return client.TryExecute" + typeArguments + + "(in operation, out result, out failure, cancellationToken);"); + source.CloseBlock(); + source.WriteLine(); + } + + // The exceptions ILuaClient.Execute and TryExecute document, and the null client these extensions check first. The + // operation is a value type, so it is never null; a mapper exception leaves the operation unchanged (see Emit). + private static void EmitExtensionExceptions(SourceBuilder source, OperationModel model, bool throwing) + { + if (model.MapperType is not null) + { + source.WriteLine("/// An exception that the result mapper throws propagates unchanged, as the " + + "same instance."); + } + + source.WriteLine("/// " + + " is ."); + source.WriteLine("/// " + + "The activation has ended."); + if (!throwing) + { + source.WriteLine("/// " + + "The activation is stopping."); + return; + } + + source.WriteLine("/// " + + "The activation is stopping, or the operation failed with ."); + source.WriteLine("/// " + + "The operation observed the cancellation of ." + + ""); + source.WriteLine("/// " + + "The operation failed with any other failure kind."); + } + + private static string EmitParameterList(EquatableArray parameters) + { + return string.Join(", ", parameters.Select(static parameter => parameter.Type + " " + parameter.Name)); + } + + private static string EmitRecordParameterList(EquatableArray parameters) + { + return string.Join(", ", parameters.Select(static parameter => parameter.Type + " " + parameter.FieldName)); + } + + private static string EmitArgumentList(EquatableArray parameters, bool fields) + { + return string.Join(", ", parameters.Select(parameter => fields ? parameter.FieldName : parameter.Name)); + } + + private static string EmitOutArgumentList(EquatableArray parameters) + { + string arguments = EmitArgumentList(parameters, true); + return arguments.Length == 0 ? "out source" : arguments + ", out source"; + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/OperationParser.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/OperationParser.cs new file mode 100644 index 0000000..e1d96b0 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/OperationParser.cs @@ -0,0 +1,125 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.SourceGenerators.Lua.Model; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// Turns one [CheatEngineLuaOperation] method into an equatable . +internal static class OperationParser +{ + public static OperationModel Parse(GeneratorAttributeSyntaxContext context) + { + LocationInfo? location = LocationInfo.From(context.TargetNode); + if (context.TargetSymbol is not IMethodSymbol method || !CheatEngineLuaGenerator.HasAttribute(method, + CheatEngineLuaGenerator.LuaGlobalAttributeMetadataName) || + method.MethodKind != MethodKind.Ordinary || + !method.IsStatic || method.IsGenericMethod || !method.IsPartialDefinition || + method.PartialImplementationPart is not null || method.ReturnsByRef || method.ReturnsByRefReadonly || + method.ContainingType.ContainingType is not null || method.ContainingType.IsFileLocal || + method.ContainingType.OriginalDefinition.TypeParameters.Length != 0 || + !method.ContainingType.IsStatic || !CheatEngineLuaGenerator.IsPartial(method.ContainingType)) + { + return Invalid(CheatEngineLuaDiagnostics.InvalidOperationShape, location); + } + + if (method.ContainingType.GetMembers(method.Name).OfType() + .Count(static candidate => CheatEngineLuaGenerator.HasAttribute(candidate, + CheatEngineLuaGenerator.LuaOperationAttributeMetadataName)) != 1) + { + return Invalid(CheatEngineLuaDiagnostics.OverloadedOperation, location, method.Name); + } + + ImmutableArray.Builder parameters = ImmutableArray.CreateBuilder(); + IParameterSymbol? outResult = null; + foreach (IParameterSymbol parameter in method.Parameters) + { + if (parameter.RefKind == RefKind.Out) + { + if (outResult is not null || parameter.Ordinal != method.Parameters.Length - 1 || + parameter.IsParams || + parameter.HasExplicitDefaultValue) + { + return Invalid(CheatEngineLuaDiagnostics.UnsupportedSignature, location, method.Name); + } + + outResult = parameter; + continue; + } + + if (parameter.RefKind != RefKind.None || parameter.IsParams || parameter.HasExplicitDefaultValue || + !CheatEngineLuaGenerator.IsScalar(parameter.Type)) + { + return Invalid(CheatEngineLuaDiagnostics.UnsupportedSignature, location, method.Name); + } + + parameters.Add(new OperationParameter(CheatEngineLuaGenerator.TypeName(parameter.Type), + CheatEngineLuaGenerator.EscapeIdentifier(parameter.Name))); + } + + if (outResult is not null && method.ReturnType.SpecialType != SpecialType.System_Boolean) + { + return Invalid(CheatEngineLuaDiagnostics.UnsupportedSignature, location, method.Name); + } + + if (outResult is null && method.ReturnsVoid) + { + return Invalid(CheatEngineLuaDiagnostics.UnsupportedSignature, location, method.Name); + } + + ITypeSymbol sourceResult = outResult?.Type ?? method.ReturnType; + AttributeData operationAttribute = context.Attributes[0]; + INamedTypeSymbol? mapper = operationAttribute.ConstructorArguments.Length == 1 + ? operationAttribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + ITypeSymbol result = sourceResult; + if (mapper is null) + { + if (!CheatEngineLuaGenerator.IsScalar(sourceResult)) + { + return Invalid(CheatEngineLuaDiagnostics.MapperRequired, location, method.Name); + } + } + else if (!CheatEngineLuaGenerator.TryGetMapperResult(mapper, sourceResult, out result)) + { + return Invalid(CheatEngineLuaDiagnostics.InvalidMapper, location, mapper.Name, method.Name); + } + else if (CheatEngineLuaGenerator.TryFindClientBoundaryViolation(sourceResult, + CheatEngineLuaGenerator.ClientBoundaryRole.MapperSource, out string sourceViolation)) + { + return Invalid(CheatEngineLuaDiagnostics.UnsafeMappedType, location, method.Name, sourceViolation); + } + else if (CheatEngineLuaGenerator.TryFindClientBoundaryViolation(result, + CheatEngineLuaGenerator.ClientBoundaryRole.ClientResult, out string resultViolation)) + { + return Invalid(CheatEngineLuaDiagnostics.UnsafeMappedType, location, method.Name, resultViolation); + } + + string operationTypeName = method.Name + "LuaOperation"; + return new OperationModel( + EquatableArray.Empty, + CheatEngineLuaGenerator.HintNameStem(method.ContainingType) + "_" + method.Name + + ".CheatEngineLuaOperation.g.cs", + method.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : method.ContainingNamespace.ToDisplayString(), + CheatEngineLuaGenerator.TypeDeclaration(method.ContainingType, true), + method.ContainingType.DeclaredAccessibility == Accessibility.Public ? "public" : "internal", + CheatEngineLuaGenerator.TypeName(method.ContainingType), + method.Name, + operationTypeName, + EquatableArray.Create(parameters.ToImmutable()), + CheatEngineLuaGenerator.TypeName(sourceResult), + CheatEngineLuaGenerator.TypeName(result), + mapper is null ? null : CheatEngineLuaGenerator.TypeName(mapper), + outResult is not null, + "Lua.Operation." + method.ContainingType.Name + "." + method.Name); + } + + private static OperationModel Invalid(DiagnosticDescriptor descriptor, LocationInfo? location, + params string[] arguments) + { + return OperationModel.Invalid(DiagnosticInfo.Create(descriptor, location, arguments)); + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md b/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md index e699ee4..c61baf7 100644 --- a/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md @@ -4,11 +4,190 @@ This Roslyn component turns explicit `CheatEngine.Client.Lua` declarations into handle-free Client adapters. It is an analyzer asset consumed by plugin projects; it is not a runtime dependency and never discovers application code through reflection. -`[CheatEngineLuaModule]` generates an `IDescribedLuaModule` adapter around the SDK-generated Lua -registration pair. It performs a protected, stack-balanced preflight of every exported global before -it writes the first one, then attempts rollback inside the same admitted `LuaRuntimeOperation` if a -registration fails. +`[CheatEngineLuaModule]` generates an `ILuaModule` around the ownership-aware registration that the CheatEngine.SDK +2.0.0 generator emits for the bindings type (`TryRegisterLuaFunctions`). The module registers with the `RejectExisting` +collision policy, so it refuses, before the first write, to replace a global that is already defined; it keeps the SDK +registration lease; and at release the lease writes a global only while it still holds the value the module installed, +so a third-party replacement survives (F12, Q16). The module never calls the legacy SDK +`RegisterLuaFunctions`/`UnregisterLuaFunctions` pair, which writes unconditionally. `[CheatEngineLuaOperation]` turns a scalar `[LuaGlobal]` declaration into a readonly value operation and strongly typed factory. Mapper calls use static abstract interface dispatch so the generated -runtime path stays trim- and AOT-friendly. +runtime path stays trim- and AOT-friendly. The operation classifies only the SDK binding call: it calls the mapper, +application code, after that classification, so an exception the mapper throws leaves `TryExecute` unchanged and the +Client rethrows it as the same instance. Next to the factory, the containing class receives an `Execute` and a +`TryExecute` extension method on `ILuaClient` for that operation: they infer the operation and result types that the +`ILuaClient.Execute` pair takes, and pass the operation by reference, so +`client.Execute(Globals.CreateReadVersionLuaOperation(address))` never boxes it. + +## What is generated for modules + +For each valid module, one partial part of the module class with three members and one field: + +- `Descriptor`: the module name and the export names, copied from the `[LuaFunction]` declarations of the bindings type, + in declaration order. The Client reserves them for the activation before `Register` runs. +- `Register()`: hands `static state => Bindings.TryRegisterLuaFunctions(state, RejectExisting)` to the registrar and keeps + the lease it returns in the field `_luaRegistration`. +- `Unregister()`: asks the registrar to release that lease and returns the `LuaModuleReleaseOutcome`. + +Once per assembly that declares a valid module, two internal files in the namespace `CheatEngine.Client.Lua.Generated`: + +- `CheatEngineLuaModuleRegistrar` takes every decision. It admits and ends the Lua operation, chooses which lease to + release (an earlier registration of the module, the residual lease of a failed publication), keeps or consumes the + module's lease, maps what CheatEngine.SDK reports to the Client vocabulary, and throws the classified refusal of a + failed registration. It makes no SDK call. +- `CheatEngineLuaRegistrationAdapter` is the only generated code that calls CheatEngine.SDK, one SDK call per member + and no decision: the Lua admission (`LuaRuntime.TryAcquireOperationWithOutcome`) and the end of the admitted + operation, the module's registration delegate, and `LuaRegistrationLease.ReleaseWithOutcome` with or without a state. + It copies every result into Client-owned values. It is a file of its own so that the EndToEnd tests can replace it + with a double that forwards each call to a managed double of the SDK registration set, because + `LuaRegistrationLease` has no public constructor. + +`Register` and `Unregister` run on Cheat Engine's main thread: the Client dispatches them. `Register`, and an +`Unregister` that CheatEngine.SDK admits, do all their Lua work inside one admitted Lua operation, which they end before +returning. An `Unregister` that CheatEngine.SDK refuses with `Detached` or `ExternalStateReset` makes no Lua operation +(see Release below). + +### Registration + +| CheatEngine.SDK reports | `Register` throws, through `CheatEngineFailure.ToException`, the exception of | +|---|---| +| Admission `Detached` or `TransitionInProgress` | `ActivationExpired`, `NotStarted`; nothing ran | +| Admission `ExternalStateReset` | `RuntimeChanged`, `NotStarted` | +| Admission `ThreadNotAdmitted` or `NoStateForThread` | `InvalidState`, `NotStarted`: called off the main thread | +| Admission `Unknown` or an unknown status | `IndeterminateHostResult`, `NotStarted`: fails closed | +| `Succeeded` with a lease | nothing: the module keeps the lease | +| `Collision` | `OperationRejected`, `NotApplied`: `Lua global '' is already defined and cannot be replaced by Client module ''.` | +| `PreflightFailed` | `LuaError`, `NotApplied`: a protected lookup failed before anything was published | +| `PublicationFailed` | `LuaError`; `NotApplied` when the SDK rollback, or the release of the residual lease the rollback left, removed everything, `CleanupUnconfirmed` otherwise. The message carries the rollback counts. | +| `Unspecified`, a success without a lease, or an unknown kind | `IndeterminateHostResult`: fails closed. `CleanupUnconfirmed` for a success without a lease, whose globals may remain with no lease to remove them; `Unknown` otherwise | + +A registration that the module still owns from an earlier call is released first, inside the same admitted operation; +a lease of an earlier attachment or Lua state is only forgotten. When that release may leave one of the module's own +globals (`PartiallyReleased`, or a result outside the documented shape), `Register` publishes nothing and throws +`LuaError` (`IndeterminateHostResult` for an unrecognized result) with `CleanupUnconfirmed` and the release counts, +instead of reporting the module's leftover global as a third party's. A `Collision` after a stale earlier registration +names that release in its message, because the global may be the module's function of an earlier attachment. When a +publication fails and the SDK compensation leaves a residual lease, the registrar releases that lease once more in the +same operation. Any other exception is an SDK fault (F15): it propagates, and `ILuaClient` classifies it through its +SDK boundary. + +### Release + +`Unregister` returns `AlreadyReleased` without any Lua call when the module owns nothing. Otherwise it consumes the +lease, releases it, and maps the SDK release kind. Because the lease is consumed before the release runs, a later +attempt could only report `AlreadyReleased`: no kind below is retryable, and a result outside the documented shape is +`CleanupUnconfirmed`, which requires manual recovery and which the Client lease leaves to the activation cleanup report. + +| `LuaRegistrationReleaseKind` | `LeaseReleaseKind` | Why | +|---|---|---| +| `Released` | `Released` | Every still-owned global was removed; replaced ones were left alone | +| `PartiallyReleased` | `PartiallyReleased` | A protected read or write failed; the failed globals are named and never retried. A partial release that names no global is `CleanupUnconfirmed`. | +| `Stale` | `RefusedRuntimeChanged` | The lease belongs to an earlier attachment or Lua state, so nothing was written. The SDK counts every entry as remaining: after a re-enable on the same Cheat Engine Lua state, the earlier functions stay in `_G` (they raise an error when called). The release therefore requires manual recovery; it is not `ExternallyRemoved`. | +| `AlreadyReleased` | `AlreadyReleased` | The lease was already consumed | +| `NotAttempted` | `CleanupUnconfirmed` | The SDK's value before any release, never the result of one (CheatEngine.SDK 2.0.0 does not return it from a release); the lease is consumed all the same | +| an unknown kind | `CleanupUnconfirmed` | Fails closed: a release began on a consumed lease and its result is not understood | + +When CheatEngine.SDK refuses the admission with `Detached` or `ExternalStateReset`, the Lua universe that holds the +registration is gone for this attachment: the lease is consumed without any Lua call and reported stale +(`RefusedRuntimeChanged`). Any other refusal keeps the registration and returns `CleanupUnavailable`, which the Client +lease retries. + +## SDK-imposed registration API + +The generated adapter uses exactly the members listed in `GeneratedLuaSurfaceRatchetTests`, each with its reason: the +admission (`LuaRuntime.TryAcquireOperationWithOutcome`, `LuaRuntimeOperation.State`, `LuaRuntimeOperation.Dispose`), +`LuaRegistrationLease.ReleaseWithOutcome` (with and without a state), and the getters of `LuaRegistrationResult`, +`LuaRegistrationFailure`, `LuaRegistrationReleaseOutcome` and `LuaRegistrationReleaseFailure`. The state of the +admitted operation is only passed to the SDK: generated code never reads or writes the Lua stack. The list is exact and +may only shrink; the module part and the registrar call no SDK member (they name SDK types and enum values only). + +## With the CheatEngine.SDK generator + +The Client generator and the CheatEngine.SDK LuaBindings generator run on the same compilation, and each consumes what +the other emits: the module calls the SDK-generated `TryRegisterLuaFunctions`, and an operation calls the SDK-generated +body of its `[LuaGlobal]` method. `RealSdkGeneratorCompositionTests` loads the SDK generator of the pinned package and +requires both outputs to compile together. Two CheatEngine.SDK 2.0.0 behaviours follow (CRIT-07): + +- **Kept functions fail closed.** `TryRegisterLuaFunctions` publishes through `LuaRegistrationSet`, which wraps each + thunk in a closure that captures the attachment and Lua state identity. A script that kept one of the module's + functions after disable, reset or re-enable gets an ordinary Lua error instead of a call into an ended activation. +- **Integers and addresses are never rounded.** The SDK integer and address marshallers refuse a Lua float at or above + 2^53. For an operation, the throwing form of the binding raises a `LuaException` whose status is `LUA_OK`, and the Try + form returns `false`; the generated operation reports both as a `LuaError` failure, with the SDK exception attached + for the throwing form, and never lets the exception escape `TryExecute`. These forms cannot tell such a refusal from + an unresolved global; the SDK Outcome form (`LuaOperationStatus`) could, but `[CheatEngineLuaOperation]` does not + support it in 1.0. + +`LuaOptional` is deferred past 1.0: an operation takes scalar inputs only (CECLUA1103), and the Client has no +contract for an omitted result yet. + +## Diagnostics + +Every generator diagnostic is an error. Ids are allocated per range and never renumbered or reused: 1001-1006 module +shape, 1101-1106 operation shape, 1201-1209 module ownership (Q16; 1205-1209 unused). Each id is tracked in +`AnalyzerReleases.Unshipped.md` (moved to `AnalyzerReleases.Shipped.md` at release); both files are `AdditionalFiles` of +the generator project, so the release-tracking analyzers (RS2000-RS2008) fail the build when a descriptor and its row +disagree. + +| Id | Title | Reported when | What to do | +|---|---|---|---| +| CECLUA1001 | Lua module must be a non-static partial class | The module is not a top-level, concrete, non-static, non-generic, non-file-local partial class. | Declare `internal sealed partial class MyModule : ILuaModule;` at namespace level. | +| CECLUA1002 | Lua module requires a static SDK bindings type | The bindings type is not a non-generic, non-file-local static class. | Point `[CheatEngineLuaModule(typeof(...))]` at the `static partial` class that holds the `[LuaFunction]` methods. | +| CECLUA1003 | Lua module bindings export nothing | The bindings type declares no `[LuaFunction]` export. | Add at least one `[LuaFunction("name")]` static method, or remove the module. | +| CECLUA1004 | Lua module exports must have unique names | An export name is missing, blank, or declared twice. | Give every `[LuaFunction]` of the bindings type its own non-blank Lua name. | +| CECLUA1005 | Lua module name cannot be blank | The explicit module name is empty or whitespace. | Pass a non-blank name, or omit it to use the module type name. | +| CECLUA1006 | Lua module needs a public constructor | Only non-public explicit constructors exist, so dependency injection cannot create the module. | Make one constructor public, or remove the explicit constructors. | +| CECLUA1101 | Lua operation requires a supported SDK global declaration | The operation is not a static partial `[LuaGlobal]` method of a top-level static partial class. | Declare `[CheatEngineLuaOperation][LuaGlobal("name")] public static partial T Name(...);` in a top-level `static partial` class. | +| CECLUA1102 | Lua operation method cannot be overloaded | Several `[CheatEngineLuaOperation]` methods share a name. | Rename the overloads: each operation needs its own method name. | +| CECLUA1103 | Lua operation has an unsupported result shape | Inputs are not scalar, or the result is not one return value or one trailing `out` value. | Use scalar inputs and one result; `LuaOptional` inputs are deferred past 1.0. | +| CECLUA1104 | Lua operation result requires a mapper | A non-scalar SDK result has no `ILuaResultMapper`. | Pass `typeof(MyMapper)` to `[CheatEngineLuaOperation]`, where `MyMapper` implements `ILuaResultMapper`. | +| CECLUA1105 | Lua operation mapper does not match the SDK result | The mapper does not implement `ILuaResultMapper` for that SDK result. | Implement `ILuaResultMapper` with `TSource` equal to the declared SDK result. | +| CECLUA1106 | Lua operation mapper must project a safe Client result | The mapped graph exposes an SDK lifetime, interop, callback, or opaque framework type. | Map to a copied value: scalars, approved SDK value types, closed immutable collections or closed DTOs of them. | +| CECLUA1201 | Lua export is owned by more than one Lua module | Two modules of one compilation export the same Lua global; reported on the later module (file path, then position). | Remove the export from one of the bindings types: a Lua global has one owning module per plugin assembly. | +| CECLUA1202 | Lua module declares a member reserved by the generated registration | The module declares `Register`, `Unregister`, `Descriptor`, `s_descriptor`, `_luaRegistration`, or an explicit implementation of `ILuaModule`. No source is generated. | Rename or remove the member; the generator implements `ILuaModule`. | +| CECLUA1203 | Lua module inherits a Lua module implementation | A base type already implements `ILuaModule` or is itself a `[CheatEngineLuaModule]`. No source is generated. | Derive the module from `object`; compose shared behavior instead of inheriting a module. | +| CECLUA1204 | Lua module annotation is not the contract type | `[CheatEngineLuaModule]` does not come from `CheatEngine.Client.Abstractions`, or a `[LuaFunction]` on the bindings type does not come from `CheatEngine.SDK.Annotations`. Look-alike exports are never counted; no source is generated. | Remove the look-alike attribute type and use the contract attributes. | + +## Limits + +- `__index`/`__newindex` metamethods on `_G` run during the SDK preflight, publication and release; their effects are + not undone (a limit of the SDK registration set). A metamethod that publishes under the module's names during + registration is unsupported. +- A third party that kept a reference to one of the module's functions can still call it after the global was removed + while the plugin stays enabled. After disable or a Lua state reset, the SDK's epoch-capturing closure makes such a + call raise an ordinary Lua error instead of entering managed code. +- CECLUA1202 checks the members the module declares. A non-module base class that declares `Register`, `Unregister` or + `Descriptor` makes the generated member hide it (compiler warning CS0108, an error under `TreatWarningsAsErrors`) + instead of reporting a CECLUA diagnostic. +- The registrar and the adapter are internal types with fixed names. Two assemblies that both declare Lua modules and + see each other's internals (`InternalsVisibleTo`) get the compiler warning CS0436 for them. +- Evidence level: C0 (the exact SDK surface of the adapter and its confinement, compile) and C1 (EndToEnd execution of + the module and the registrar against a managed double of the SDK registration set, which compares values by object + identity). There is no Lua 5.3 fixture in this repository, so there is no C2 evidence, and nothing here is + host-qualified; the C4 observation is the Q16 scenario of the Coexistence protocol + (tests/CheatEngine.Client.LivePlugin.Coexistence). The epoch-capturing closure of a kept function runs inside the + SDK registration set, so its only evidence here is that the module publishes through `LuaRegistrationSet` (the + composition test); the host behaviour is Q16. + +## Tests + +`tests/CheatEngine.Client.SourceGenerators.Lua.Tests` backs each statement above. The EndToEnd tests replace only the +generated SDK adapter; the module and the registrar run unchanged. + +| Promise | Tests | +|---|---| +| Release writes only still-owned globals and reports the SDK facts | `LuaModuleOwnershipEndToEndTests.UnregisterRemovesEveryExportTheModuleStillOwns`, `UnregisterLeavesAThirdPartyReplacementUntouched`, `UnregisterTreatsAWrappedFunctionAsAReplacement`, `UnregisterRemovesAValueAThirdPartyRestoredToTheModulesOwn`, `UnregisterCountsAnExportThatIsAlreadyAbsentAsAReplacementAndWritesNothing`, `ThirdPartyValuesOfAnyLuaTypeAreCountedAsReplacements` | +| Stale registrations write nothing and require manual recovery; a partial release is never retried; a refused admission keeps the registration | `UnregisterAfterALuaStateReplacementWritesNothingAndReportsRefusedRuntimeChanged`, `UnregisterAfterAReattachWritesNothingAndLeavesTheEarlierGlobalsInPlace`, `UnregisterWithoutTheLuaUniverseConsumesTheRegistrationAsStaleWithoutLua`, `UnregisterWithoutAdmissionKeepsTheRegistrationForALaterAttempt`, `UnregisterReportsIndependentFailuresAsAPartialReleaseThatIsNeverRetried` | +| Registration refuses before any write and classifies every failure | `RegisterRefusesAnOccupiedExportBeforeAnyWrite`, `RegisterReportsAPreflightReadFailureBeforeAnyWrite`, `RegisterRollsBackWhatItPublishedWhenPublicationFails`, `RegisterReportsARollbackCompletedByTheResidualReleaseAsNotApplied`, `RegisterReportsARollbackThatLeftAGlobalAsAnUnconfirmedCleanup`, `RegisterWithoutAdmissionIsRefusedBeforeAnyLuaCall` | +| Each call runs in one admitted operation that it ends; the module registers with `RejectExisting` | `RegisterAndUnregisterEachRunInOneAdmittedOperationThatTheyEnd`, `AFailedPublicationEndsItsOperationAfterReleasingTheResidualLease`, `RegisterAgainReleasesTheEarlierRegistrationFirst` | +| Registering again reports the release of the earlier registration instead of a false collision | `RegisterAgainPublishesNothingWhenTheEarlierReleaseLeftAGlobal`, `RegisterAgainAfterAReattachNamesTheStaleReleaseInTheCollision` | +| A release outside the SDK shape is an unconfirmed cleanup, never retried | `AReleaseOutsideTheSdkShapeIsAnUnconfirmedCleanupThatIsNeverRetried` | +| Every SDK outcome enum is mapped totally and fails closed | `GeneratedRegistrarMappingTests` | +| The SDK surface is exact and confined to the adapter | `GeneratedLuaSurfaceRatchetTests` | +| No legacy registration; contract, projection and emitted text compared separately; no `unsafe` code required; refused without an attached SDK runtime | `ModuleContractTests`, `ModuleSnapshots` | +| The Client and SDK generators compile together; the module publishes through `LuaRegistrationSet`; integer results use the refusing marshallers | `RealSdkGeneratorCompositionTests` | +| A refused integer result is a `LuaError` failure and never an exception out of `TryExecute` | `OperationRefusalEndToEndTests` | +| Diagnostics are tracked, located and deterministic; models stay cached | `CheatEngineLuaDiagnosticCatalogTests`, `ModuleShapeDiagnosticTests`, `IncrementalityTests`, `IdentifierStabilityTests` | + +The EndToEnd and outcome tests carry `[Trait("Qualification", "Q16")]`. diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/RegistrarEmitter.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/RegistrarEmitter.cs new file mode 100644 index 0000000..5ef159d --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/RegistrarEmitter.cs @@ -0,0 +1,489 @@ +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// +/// Emits, once per compilation that declares a valid [CheatEngineLuaModule], the internal registrar that every +/// generated module of that assembly calls, and the CheatEngine.SDK adapter behind it. +/// +/// +/// +/// The registrar () takes every decision: when to admit a Lua operation and end it, +/// which lease to release (an earlier registration of the module, the residual lease a failed publication left), +/// when to consume the module's lease, how to map the admission, the registration result and the release that +/// CheatEngine.SDK reports to the Client vocabulary, and which classified refusal to throw. It makes no SDK call. +/// +/// +/// The adapter () is the only generated code that calls CheatEngine.SDK, and it takes no +/// decision: each of its members is one SDK call (LuaRuntime.TryAcquireOperationWithOutcome, the end of +/// the admitted operation, the module's TryRegisterLuaFunctions delegate, and +/// LuaRegistrationLease.ReleaseWithOutcome with or without a state) whose result it copies into Client-owned +/// values. It is emitted as its own file so that a test compilation can replace it with a managed double of the +/// SDK registration set (LuaRegistrationLease has no public constructor), while the registrar and the +/// modules run unchanged. The SDK members it may use are the exact allowlist of +/// GeneratedLuaSurfaceRatchetTests. +/// +/// +internal static class RegistrarEmitter +{ + /// The namespace of the generated registrar and adapter. + internal const string Namespace = "CheatEngine.Client.Lua.Generated"; + + /// The simple name of the generated registrar that modules call. + internal const string RegistrarType = "CheatEngineLuaModuleRegistrar"; + + /// The simple name of the generated CheatEngine.SDK adapter. + internal const string AdapterType = "CheatEngineLuaRegistrationAdapter"; + + /// The hint name of the registrar file. + internal const string RegistrarHintName = Namespace + "." + RegistrarType + ".g.cs"; + + /// The hint name of the adapter file. + internal const string AdapterHintName = Namespace + "." + AdapterType + ".g.cs"; + + /// The registrar and the Client-owned values the adapter fills; constant text. + internal const string RegistrarSource = + """ + // + #nullable enable + + namespace CheatEngine.Client.Lua.Generated; + + /// + /// Registers and releases this assembly's [CheatEngineLuaModule] modules through CheatEngine.SDK Lua + /// registration leases and reports what CheatEngine.SDK observed in the Client vocabulary. It runs inside a + /// module's Register and Unregister, on Cheat Engine's main thread, and takes every decision; each + /// CheatEngine.SDK call goes through one member of . + /// + internal static class CheatEngineLuaModuleRegistrar + { + private const string RegisterOperation = "Lua.RegisterModule"; + + /// Publishes a module's globals and keeps the registration lease in . + /// + /// CheatEngine.SDK refused the Lua admission, a global is already defined, or a protected operation failed; + /// the exception type follows the kind of the failure (CheatEngineFailure.ToException). + /// + internal static void Register(global::CheatEngine.Client.Lua.LuaModuleDescriptor descriptor, ref object? registration, + global::System.Func publish) + { + global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus admission = CheatEngineLuaRegistrationAdapter.TryAdmit( + out global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation, + out global::CheatEngine.SDK.Lua.State.LuaState state); + if (admission != global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Admitted) + { + // Nothing ran: a registration the module already owned is still owned. + throw Refused(admission).ToException(); + } + + CheatEngineLuaPublication publication; + CheatEngineLuaRelease previous = default; + CheatEngineLuaRelease residual = default; + try + { + object? owned = registration; + if (owned is not null) + { + // Released inside this admitted operation before the same globals are published again: a current + // lease is released ownership-aware, a lease of an earlier attachment or Lua state is only forgotten. + registration = null; + previous = CheatEngineLuaRegistrationAdapter.Release(owned, state); + if (!LeftNothingOwned(previous.Kind)) + { + // A global of the earlier registration may still hold the module's own function: publishing + // would report it as a third party's. Nothing is published, and the release is the failure. + throw NotReleased(descriptor.Name, previous).ToException(); + } + } + + publication = CheatEngineLuaRegistrationAdapter.Publish(publish, state); + if (publication.Lease is not null && + publication.Kind != global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Succeeded) + { + // The SDK compensation left a residual owner: release it once more inside this admitted operation. + residual = CheatEngineLuaRegistrationAdapter.Release(publication.Lease, state); + } + } + finally + { + CheatEngineLuaRegistrationAdapter.EndOperation(ref operation); + } + + if (publication.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Succeeded && + publication.Lease is not null) + { + registration = publication.Lease; + return; + } + + throw Failed(descriptor.Name, publication, previous, residual).ToException(); + } + + /// Releases the registration lease in and reports what happened. + internal static global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome Unregister( + global::CheatEngine.Client.Lua.LuaModuleDescriptor descriptor, ref object? registration) + { + object? lease = registration; + if (lease is null) + { + return global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome.AlreadyReleased(descriptor.Name); + } + + global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus admission = CheatEngineLuaRegistrationAdapter.TryAdmit( + out global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation, + out global::CheatEngine.SDK.Lua.State.LuaState state); + CheatEngineLuaRelease release; + switch (admission) + { + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Admitted: + registration = null; + try + { + release = CheatEngineLuaRegistrationAdapter.Release(lease, state); + } + finally + { + CheatEngineLuaRegistrationAdapter.EndOperation(ref operation); + } + + return ToOutcome(descriptor, release); + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Detached: + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.ExternalStateReset: + // The Lua universe that holds the registration is no longer this attachment's: the lease is consumed + // without any Lua operation, and CheatEngine.SDK reports it stale. + registration = null; + return ToOutcome(descriptor, CheatEngineLuaRegistrationAdapter.ReleaseStale(lease)); + default: + // Nothing ran: the registration stays owned for a later release or the activation cleanup. + return global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome.CleanupUnavailable(descriptor.Name, + descriptor.Exports.Length); + } + } + + /// + /// Maps the kind CheatEngine.SDK reports for the release of a lease the registrar consumed to the Client lease + /// vocabulary; total and fail-closed. + /// + /// + /// The lease is consumed before the release runs, so a later attempt could only report + /// AlreadyReleased: a kind outside the documented release shape is never retryable. It is + /// CleanupUnconfirmed, which requires manual recovery and which the activation cleanup reports. + /// + internal static global::CheatEngine.Client.Results.LeaseReleaseKind MapReleaseKind( + global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind kind) + { + switch (kind) + { + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Released: + return global::CheatEngine.Client.Results.LeaseReleaseKind.Released; + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.PartiallyReleased: + return global::CheatEngine.Client.Results.LeaseReleaseKind.PartiallyReleased; + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Stale: + // An earlier attachment or Lua state: nothing was written, and its globals may remain. + return global::CheatEngine.Client.Results.LeaseReleaseKind.RefusedRuntimeChanged; + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.AlreadyReleased: + return global::CheatEngine.Client.Results.LeaseReleaseKind.AlreadyReleased; + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.NotAttempted: + // The SDK's value before any release, never the result of one: the lease is consumed all the same. + default: + // A kind a later CheatEngine.SDK adds: the release began on a consumed lease and is not confirmed. + return global::CheatEngine.Client.Results.LeaseReleaseKind.CleanupUnconfirmed; + } + } + + /// Classifies a Lua admission that CheatEngine.SDK refused; total and fail-closed. + internal static global::CheatEngine.Client.Results.CheatEngineFailureKind MapAdmission( + global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus status) + { + switch (status) + { + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Detached: + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.TransitionInProgress: + return global::CheatEngine.Client.Results.CheatEngineFailureKind.ActivationExpired; + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.ExternalStateReset: + return global::CheatEngine.Client.Results.CheatEngineFailureKind.RuntimeChanged; + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.ThreadNotAdmitted: + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.NoStateForThread: + return global::CheatEngine.Client.Results.CheatEngineFailureKind.InvalidState; + case global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Unknown: + default: + // A status the Client does not recognize fails closed, like every other SDK outcome. + return global::CheatEngine.Client.Results.CheatEngineFailureKind.IndeterminateHostResult; + } + } + + /// Classifies a registration result that is not a success; total and fail-closed. + internal static global::CheatEngine.Client.Results.CheatEngineFailureKind MapResultKind( + global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind kind) + { + switch (kind) + { + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Collision: + return global::CheatEngine.Client.Results.CheatEngineFailureKind.OperationRejected; + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PreflightFailed: + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PublicationFailed: + return global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError; + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Unspecified: + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Succeeded: + default: + // No result, a success without a registration lease, or a kind the Client does not + // recognize: a result CheatEngine.SDK cannot attribute, never a success. + return global::CheatEngine.Client.Results.CheatEngineFailureKind.IndeterminateHostResult; + } + } + + /// Copies the CheatEngine.SDK release of a consumed lease into the Client outcome of one module. + internal static global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome ToOutcome( + global::CheatEngine.Client.Lua.LuaModuleDescriptor descriptor, CheatEngineLuaRelease release) + { + global::CheatEngine.Client.Results.LeaseReleaseKind kind = MapReleaseKind(release.Kind); + string[] failed = release.FailedExports ?? global::System.Array.Empty(); + if (kind != global::CheatEngine.Client.Results.LeaseReleaseKind.PartiallyReleased) + { + failed = global::System.Array.Empty(); + } + else if (failed.Length == 0) + { + // A partial release that names no failed global is outside the documented result shape. + kind = global::CheatEngine.Client.Results.LeaseReleaseKind.CleanupUnconfirmed; + } + + int removed = global::System.Math.Max(release.RemovedCount, 0); + int restored = global::System.Math.Max(release.RestoredCount, 0); + int replaced = global::System.Math.Max(release.ReplacementCount, 0); + int remaining = global::System.Math.Max(release.RemainingCount, failed.Length); + if (kind == global::CheatEngine.Client.Results.LeaseReleaseKind.CleanupUnconfirmed) + { + // Every global the release did not report as handled may remain. + remaining = global::System.Math.Max(remaining, descriptor.Exports.Length - removed - restored - replaced); + } + + return global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome.Create(descriptor.Name, kind, removed, restored, + replaced, remaining, global::System.Collections.Immutable.ImmutableArray.Create(failed)); + } + + private static global::CheatEngine.Client.Results.CheatEngineFailure Refused( + global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus admission) + { + return new global::CheatEngine.Client.Results.CheatEngineFailure(MapAdmission(admission), RegisterOperation, + "CheatEngine.SDK refused the Lua operation (" + admission.ToString() + "); nothing was registered.", + null, global::CheatEngine.Client.Results.CheatEngineHostEffect.NotStarted); + } + + // Whether the release of the registration a module already owned left nothing of it that a publication could meet. + private static bool LeftNothingOwned(global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind kind) + { + switch (kind) + { + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Released: + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.AlreadyReleased: + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Stale: + return true; + default: + return false; + } + } + + private static global::CheatEngine.Client.Results.CheatEngineFailure NotReleased(string moduleName, + CheatEngineLuaRelease previous) + { + return new global::CheatEngine.Client.Results.CheatEngineFailure( + previous.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.PartiallyReleased + ? global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError + : global::CheatEngine.Client.Results.CheatEngineFailureKind.IndeterminateHostResult, + RegisterOperation, + "Client Lua module '" + moduleName + "' could not release its earlier registration before registering " + + "again; nothing was published. " + Describe("The release", previous), + null, global::CheatEngine.Client.Results.CheatEngineHostEffect.CleanupUnconfirmed); + } + + private static global::CheatEngine.Client.Results.CheatEngineFailure Failed(string moduleName, + CheatEngineLuaPublication publication, CheatEngineLuaRelease previous, CheatEngineLuaRelease residual) + { + string name = publication.FailedExport ?? "(unnamed)"; + string status = publication.FailedStatus ?? "(unknown)"; + global::CheatEngine.Client.Results.CheatEngineFailureKind kind = MapResultKind(publication.Kind); + // Outside the documented shape, a success without its lease may have published globals that no lease + // can remove, like an Auto Assembler script applied without its owner; any other such result says + // nothing of what was published. + bool unowned = publication.Kind == + global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Succeeded; + switch (publication.Kind) + { + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Collision: + // After a stale earlier registration, the global may be the module's own function of an earlier + // attachment: the message says what that release reported. + return new global::CheatEngine.Client.Results.CheatEngineFailure(kind, RegisterOperation, + "Lua global '" + name + "' is already defined and cannot be replaced by Client module '" + + moduleName + "'." + + (previous.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.NotAttempted + ? string.Empty + : " " + Describe("The release of the module's earlier registration", previous)), + null, global::CheatEngine.Client.Results.CheatEngineHostEffect.NotApplied); + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PreflightFailed: + return new global::CheatEngine.Client.Results.CheatEngineFailure(kind, RegisterOperation, + "Client Lua module '" + moduleName + "' could not read Lua global '" + name + + "' before its registration (" + status + "); nothing was published.", + null, global::CheatEngine.Client.Results.CheatEngineHostEffect.NotApplied); + case global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PublicationFailed: + return new global::CheatEngine.Client.Results.CheatEngineFailure(kind, RegisterOperation, + "Client Lua module '" + moduleName + "' could not publish Lua global '" + name + "' (" + status + + "). " + Describe("The rollback", publication.Rollback) + + (residual.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.NotAttempted + ? string.Empty + : " " + Describe("The release of what the rollback left", residual)), + null, IsCleanedUp(publication.Rollback, residual) + ? global::CheatEngine.Client.Results.CheatEngineHostEffect.NotApplied + : global::CheatEngine.Client.Results.CheatEngineHostEffect.CleanupUnconfirmed); + default: + return new global::CheatEngine.Client.Results.CheatEngineFailure(kind, RegisterOperation, + "Client Lua module '" + moduleName + "' received the registration result " + + publication.Kind.ToString() + " without a registration lease from CheatEngine.SDK.", + null, unowned + ? global::CheatEngine.Client.Results.CheatEngineHostEffect.CleanupUnconfirmed + : global::CheatEngine.Client.Results.CheatEngineHostEffect.Unknown); + } + } + + // Nothing the failed publication installed remains: the rollback, or the release of what it left, removed it all. + private static bool IsCleanedUp(CheatEngineLuaRelease rollback, CheatEngineLuaRelease residual) + { + return residual.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.NotAttempted + ? rollback.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Released + : residual.Kind == global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Released; + } + + private static string Describe(string step, CheatEngineLuaRelease release) + { + string[] failed = release.FailedExports ?? global::System.Array.Empty(); + return step + " reported " + release.Kind.ToString() + " (removed " + + release.RemovedCount.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + ", replaced " + + release.ReplacementCount.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + ", remaining " + + release.RemainingCount.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + + (failed.Length == 0 ? string.Empty : ", failed " + string.Join(", ", failed)) + ")."; + } + } + + /// What CheatEngine.SDK reported for one release of a registration lease, copied without any Lua handle. + internal readonly struct CheatEngineLuaRelease + { + internal readonly global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind Kind; + internal readonly int RemovedCount; + internal readonly int RestoredCount; + internal readonly int ReplacementCount; + internal readonly int RemainingCount; + internal readonly string[]? FailedExports; + + internal CheatEngineLuaRelease(global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind kind, + int removedCount, int restoredCount, int replacementCount, int remainingCount, string[] failedExports) + { + Kind = kind; + RemovedCount = removedCount; + RestoredCount = restoredCount; + ReplacementCount = replacementCount; + RemainingCount = remainingCount; + FailedExports = failedExports; + } + } + + /// What CheatEngine.SDK reported for one registration, copied; only the lease stays an SDK object. + internal readonly struct CheatEngineLuaPublication + { + internal readonly global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind Kind; + internal readonly object? Lease; + internal readonly string? FailedExport; + internal readonly string? FailedStatus; + internal readonly CheatEngineLuaRelease Rollback; + + internal CheatEngineLuaPublication(global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind kind, + object? lease, string? failedExport, string? failedStatus, CheatEngineLuaRelease rollback) + { + Kind = kind; + Lease = lease; + FailedExport = failedExport; + FailedStatus = failedStatus; + Rollback = rollback; + } + } + + """; + + /// The CheatEngine.SDK adapter; constant text, and the only generated code that calls the SDK. + internal const string AdapterSource = + """ + // + #nullable enable + + namespace CheatEngine.Client.Lua.Generated; + + /// + /// The only generated code that calls CheatEngine.SDK. Each member is one SDK call: the Lua admission and the end + /// of the admitted operation, the SDK-generated registration of a module, and the release of a registration + /// lease. It copies every result into Client-owned values and takes no decision; + /// does. + /// + internal static class CheatEngineLuaRegistrationAdapter + { + /// + /// Asks CheatEngine.SDK to admit one Lua operation on this thread. When it is admitted, + /// holds it until and is its + /// state; otherwise nothing is held. + /// + internal static global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus TryAdmit( + out global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation, + out global::CheatEngine.SDK.Lua.State.LuaState state) + { + global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus admission = + global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryAcquireOperationWithOutcome(out operation); + state = admission == global::CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Admitted + ? operation.State + : default; + return admission; + } + + /// Ends an admitted operation before control returns to Cheat Engine. + internal static void EndOperation(ref global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation) + { + operation.Dispose(); + } + + /// Runs the module's SDK-generated registration with the state of an admitted operation. + internal static CheatEngineLuaPublication Publish( + global::System.Func publish, + global::CheatEngine.SDK.Lua.State.LuaState state) + { + global::CheatEngine.SDK.Lua.Registration.LuaRegistrationResult result = publish(state); + global::CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure? failure = result.Failure; + return new CheatEngineLuaPublication(result.Kind, result.Lease, + failure.HasValue ? failure.GetValueOrDefault().Name : null, + failure.HasValue ? failure.GetValueOrDefault().LuaStatus.ToString() : null, + Copy(result.Rollback)); + } + + /// Releases a registration lease with the state of an admitted operation. + internal static CheatEngineLuaRelease Release(object lease, global::CheatEngine.SDK.Lua.State.LuaState state) + { + return Copy(((global::CheatEngine.SDK.Lua.Registration.LuaRegistrationLease) lease).ReleaseWithOutcome(state)); + } + + /// Consumes a registration lease without a Lua state; CheatEngine.SDK reports it stale. + internal static CheatEngineLuaRelease ReleaseStale(object lease) + { + return Copy(((global::CheatEngine.SDK.Lua.Registration.LuaRegistrationLease) lease).ReleaseWithOutcome()); + } + + private static CheatEngineLuaRelease Copy(global::CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome outcome) + { + global::System.Collections.Generic.IReadOnlyList failures = + outcome.Failures; + string[] failed = new string[failures.Count]; + for (int index = 0; index < failed.Length; index++) + { + failed[index] = failures[index].Name; + } + + return new CheatEngineLuaRelease(outcome.Kind, outcome.RemovedCount, outcome.RestoredCount, + outcome.ReplacementCount, outcome.RemainingCount, failed); + } + } + + """; +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/SourceBuilder.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/SourceBuilder.cs new file mode 100644 index 0000000..bca6385 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/SourceBuilder.cs @@ -0,0 +1,65 @@ +using System.Text; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// Tab-indented text writer for generated C# sources. +internal sealed class SourceBuilder +{ + private readonly StringBuilder _source = new(); + private int _indent; + + public void WriteGeneratedHeader() + { + WriteLine("// "); + WriteLine("#nullable enable"); + WriteLine(); + } + + public void OpenNamespace(string @namespace) + { + if (string.IsNullOrEmpty(@namespace)) + { + return; + } + + WriteLine("namespace " + @namespace + ";"); + WriteLine(); + } + + public void OpenBlock() + { + WriteLine("{"); + Indent(); + } + + public void CloseBlock() + { + Unindent(); + WriteLine("}"); + } + + public void WriteLine(string value = "") + { + if (value.Length != 0) + { + _source.Append('\t', _indent); + } + + _source.AppendLine(value); + } + + public void Indent() + { + _indent++; + } + + public void Unindent() + { + _indent--; + } + + public override string ToString() + { + return _source.ToString(); + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json b/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json index 49c5cce..4373bcc 100644 --- a/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json @@ -26,16 +26,11 @@ "System.Threading.Tasks.Extensions": "4.6.3" } }, - "Microsoft.SourceLink.GitHub": { + "MinVer": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" }, "NETStandard.Library": { "type": "Direct", @@ -46,14 +41,6 @@ "Microsoft.NETCore.Platforms": "1.1.0" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", "resolved": "5.9.0", @@ -75,11 +62,6 @@ "resolved": "1.1.0", "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "System.Buffers": { "type": "Transitive", "resolved": "4.6.1", @@ -94,15 +76,6 @@ "System.Runtime.CompilerServices.Unsafe": "6.1.2" } }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==", - "dependencies": { - "System.Buffers": "4.6.1", - "System.Memory": "4.6.3" - } - }, "System.Memory": { "type": "Transitive", "resolved": "4.6.3", diff --git a/src/CheatEngine.Client/CheatEngine.Client.csproj b/src/CheatEngine.Client/CheatEngine.Client.csproj index 8ee8482..8801e22 100644 --- a/src/CheatEngine.Client/CheatEngine.Client.csproj +++ b/src/CheatEngine.Client/CheatEngine.Client.csproj @@ -4,6 +4,7 @@ false $(NoWarn);NU5128 + The package a normal in-process Cheat Engine plugin references: brings CheatEngine.Client.Hosting and CheatEngine.Client.Fluent. Reference CheatEngine.SDK directly next to it. diff --git a/src/CheatEngine.Client/PublicAPI.Shipped.txt b/src/CheatEngine.Client/PublicAPI.Shipped.txt deleted file mode 100644 index 7dc5c58..0000000 --- a/src/CheatEngine.Client/PublicAPI.Shipped.txt +++ /dev/null @@ -1 +0,0 @@ -#nullable enable diff --git a/src/CheatEngine.Client/PublicAPI.Unshipped.txt b/src/CheatEngine.Client/PublicAPI.Unshipped.txt deleted file mode 100644 index 7dc5c58..0000000 --- a/src/CheatEngine.Client/PublicAPI.Unshipped.txt +++ /dev/null @@ -1 +0,0 @@ -#nullable enable diff --git a/src/CheatEngine.Client/README.md b/src/CheatEngine.Client/README.md index c7ce726..9880a1a 100644 --- a/src/CheatEngine.Client/README.md +++ b/src/CheatEngine.Client/README.md @@ -1,57 +1,255 @@ # CheatEngine.Client -## Context +High-level, lifecycle-safe C# APIs for in-process Cheat Engine plugins, built on +[CheatEngine.SDK](https://www.nuget.org/packages/CheatEngine.SDK). -`CheatEngine.Client` is the umbrella package for a normal in-process Cheat Engine plugin. It is the composition root -of the Client delivery graph: it combines Hosting and Fluent APIs over the public Client contracts, while keeping the -SDK-facing Core implementation behind the DI registration boundary. The package itself intentionally contains no +`CheatEngine.Client` is the package a plugin references. It brings the activation-scoped host +([`CheatEngine.Client.Hosting`](https://www.nuget.org/packages/CheatEngine.Client.Hosting)), the fluent memory and AOB +builders ([`CheatEngine.Client.Fluent`](https://www.nuget.org/packages/CheatEngine.Client.Fluent)) and, through them, +the public contracts and the SDK-facing implementation, at exactly its own version. The package itself contains no Cheat Engine host logic. -## Why this project exists +The Client runs in process inside an enabled Cheat Engine plugin. It is not a standalone executable, not Cheat Engine's +`luaclient` library and not an RPC client of `ceserver`. -Most plugin projects should take one Client package rather than recreate the Client package graph. This façade is that -stable installation point: Hosting supplies the activation-scoped DI lifecycle, Fluent supplies immutable request -builders, and Core is composed internally through Hosting's DI registration. +## Requirements -It deliberately does not replace `CheatEngine.SDK`. A plugin must reference the SDK **directly** so its build assets -can generate the Cheat Engine entry point and copy the native Lua bridge. Those host-bound assets do not flow through -an ordinary transitive NuGet dependency. +| Plugin project requirement | Value | +|---|---| +| Target framework | `net10.0` (`CECLIENT005`) | +| Language | C# 14, `14.0` (`CECLIENT006`) | +| .NET SDK | 10.0.401 or later: the Lua generator packed in Hosting is compiled against Roslyn 5.9.0 | +| Platform | Windows x64; `PlatformTarget` is `x64` or `AnyCPU` (`CECLIENT007`) | +| Cheat Engine | 7.7.0.10621 x64 (`cheatengine-x86_64.exe`), loading the plugin through its managed .NET host | +| `CheatEngine.SDK` | A direct `PackageReference` in `[2.0.0, 3.0.0)` (`CECLIENT001`, `CECLIENT017`, `NU1605`) | +| Other Client packages | None: `CheatEngine.Client` brings them, each at exactly its own version | + +The codes in parentheses are the build or restore errors that enforce a row; the +[Hosting README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Hosting/README.md#build-diagnostics) +lists every `CECLIENT` build diagnostic. No build error enforces the .NET SDK row: an older compiler does not run the +Lua generator and reports only warning CS9057, and only `dotnet new ceplugin` refuses an older SDK. + +## Installation + +Start from the `ceplugin` template, which writes the project below and a complete example plugin: + +```powershell +dotnet new install CheatEngine.Client.Templates +dotnet new ceplugin --name MyPlugin +cd MyPlugin +dotnet build --configuration Release +``` + +To add the Client to an existing plugin project, keep these properties and direct references: ```xml net10.0 14.0 + enable + enable x64 true - - + + + ``` -When `CheatEngineClientPluginProject` is enabled, the Hosting build target emits `CECLIENT001` if that direct SDK -reference is missing. +Replace `X.Y.Z` with the CheatEngine.Client version you install; the template writes it for you. +`Microsoft.Extensions.Configuration.Json` is needed only to load an `appsettings.json` file, as below. + +`CheatEngine.SDK` must be referenced **directly** by the plugin project: its build assets generate the Cheat Engine +entry point and copy the native Lua bridge, and they do not flow through a transitive NuGet dependency. +`CheatEngineClientPluginProject` turns on the Hosting build checks of the plugin profile, which fail the build when that +reference is missing (`CECLIENT001`). Keep `CheatEngine.SDK` on 2.x: this Client release is built and tested against +CheatEngine.SDK 2.0.0 and declares `[2.0.0, 3.0.0)`. A 3.x SDK fails the build with `CECLIENT017`, and a version below +2.0.0 fails the restore with `NU1605`. Do not upgrade to 3.x until a Client release says so. + +Deploy the complete framework-dependent build output as one folder: the plugin assembly, its `.deps.json` and +`.runtimeconfig.json`, the Client and SDK assemblies and the SDK's `cheatengine-sdk-lua-bridge.dll`. A Native AOT +plugin DLL is not a supported Cheat Engine plugin. + +## A minimal plugin + +The SDK owns the plugin annotation; `CheatEngineClientPlugin` builds a new, validated service provider for every enable +and gives each module the activation-scoped `ICheatEngineClient`: + +```csharp +using CheatEngine.Client; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Annotations.Plugin; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace MyPlugin; + +[CheatEnginePlugin("My Plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + builder.Configuration + .SetBasePath(builder.PluginDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + + builder.Client.AddModule(); + } +} + +public sealed class ScoreModule(ILogger logger) : ICheatEngineClientModule +{ + public void OnEnabled(ICheatEngineClient client) + { + // A Try form returns an expected failure, such as no selected process, instead of throwing it. + if (!client.Patterns.Aob("48 8B ?? ?? ?? 89") + .InModule("game.exe") + .Executable() + .FirstOrNone() + .TryExecute(out Address? match, out CheatEngineFailure failure)) + { + logger.LogDebug("Score probe skipped: {Failure}", failure); + return; + } + + if (match is { } address) + { + client.Memory.At(address + 0x14).Write(999); + } + } + + public void OnDisabling(ICheatEngineClient client) + { + } +} +``` + +`CheatEngineFailure.ToString()` names the kind, the operation and the host effect only: never an address, a value or a +path. Never keep the client, a lease or a target-bound value after `OnDisabling`: every enable creates a new client. +The [repository README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/README.md#the-plugin-lifecycle) +describes the lifecycle. + +## What 1.0 offers + +**Available** means a stable 1.x API with an operational implementation. **Experimental** APIs are operational too, but +they carry an `[Experimental("CECLIENT500x")]` diagnostic and can change in a minor release until their live scenarios +pass; suppressing the diagnostic is the opt-in. At run time, `ICheatEngineRuntime.TryGetClientCapability` reports each +capability with its evidence, and none reports the runtime state `Available` before Client qualification receipts +exist for the scenarios named below. + + +| Capability id | Implementation | 1.0 status | What you get | Qualification | +|---|---|---|---|---| +| `Client.ProcessSelection` | Operational adapter | Available | `client.Processes`: the selected target, attach, the local process catalog | Unknown until Client receipts for Q30.a, Q31 and Q32 exist | +| `Client.TypedMemory` | Operational adapter | Available | `client.Memory`: primitives, codecs, bytes, strings, pointer chains, batches | Unknown until Client receipts for Q20, Q21 and Q33 exist | +| `Client.PatternScanning` | Operational adapter | Available | `client.Patterns`: AOB scans with a bounded copy | Unknown until Client receipts for Q27, Q28 and Q29 exist | +| `Client.Inspection` | Operational adapter | Available | `client.Inspection`: modules, regions, symbols and symbol leases | Unknown until Client receipts for Q16.b and Q28 exist | +| `Client.Tables` | Operational adapter | Available | `client.Tables`: Address List records and trusted table files | Unknown until Client receipts for Q34 exist | +| `Client.ProtectedLua` | Operational adapter | Available | `client.Lua`: typed Lua operations and generated Lua modules | Unknown until Client receipts for Q05, Q16 and Q19 exist | +| `Client.UnsafeLuaExecution` | Operational, policy opt-in | Available with `EnableUnsafeLuaExecution()` | `IUnsafeLuaClient`: trusted Lua source, never a Lua state | Stays `Unknown`: no scenario covers arbitrary Lua | +| `Client.ValueScanning` | Operational adapter, experimental (CECLIENT5001) | Experimental | `client.ValueScans`: first and next scans, bounded pages | Unknown until Client receipts for Q25 and Q26 exist | +| `Client.Allocations` | Operational adapter, experimental (CECLIENT5002) | Experimental | `client.Allocations`: target allocations owned by leases | Unknown until Client receipts for Q30.a exist | +| `Client.Assembly` | Operational adapter, experimental (CECLIENT5003) | Experimental | `client.Assembly`: single-instruction assembly and disassembly | Unknown until Client receipts for Q32 exist | +| `Client.AutoAssemblerPatches` | Operational, policy opt-in, experimental (CECLIENT5004) | Experimental, with `EnableAutoAssemblerPatches()` | `IAutoAssemblerClient`: Auto Assembler patches owned by leases | Unknown until Client receipts for Q35 and Q44 exist | + + +The activation lifecycle, main-thread dispatch (`client.Dispatcher`), runtime facts (`client.Runtime`), dependency +injection, options and modules are always available. The +[Abstractions README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md) +is the reference for every contract, its failures and its limits, and describes each experimental API under its +diagnostic id. + +**Not offered in 1.0**, not even as a gated placeholder: timers and hotkeys; the debugger and breakpoints; the speed +hack; target-memory and file hashing; DBVM; remote execution and DLL injection; pausing, resuming or creating a process, +and attaching to the foreground process; assembly comments; and detaching from a process. No CheatEngine.SDK primitive +backs these yet; they may arrive in a 1.x minor release once the SDK provides an owner. IPC and remote clients, UI and +forms, structures, Mono and IL2CPP, and advanced ABI hooks are outside the scope of 1.0 and have no public API either. + +## Supported host profile + +This Client release consumes CheatEngine.SDK 2.0.0 and names one Cheat Engine host profile, the qualifiable profile +that CheatEngine.SDK 2.0.0 names: this tuple is what a plugin deploys against. A profile is what a qualification result +can name; it is not itself a qualification result. + +| Item | Value | +|---|---| +| Profile id | `ce-7.7.0.10621-x64-managed-hostfxr` | +| Host executable | `cheatengine-x86_64.exe` 7.7.0.10621, machine AMD64, SHA-256 `9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d`; not the `Cheat Engine.exe` launcher and not the `cheatengine-x86_64-SSE4-AVX2.exe` variant | +| Load profile | `managed-hostfxr`: the plugin is a framework-dependent .NET component started by Cheat Engine's nethost/hostfxr route | +| Runtime configuration | The qualification host's `ce.runtimeconfig.json` (`net10.0`), SHA-256 `68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b`, is a local modification, not an installer baseline | +| Consumed SDK package | `CheatEngine.SDK` 2.0.0, source commit `325c47b573f8bd39a247f1d0101f110fa36c1696`, NuGet content hash (SHA-512, base64) `NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==` | +| SDK native bridge | `build/native/cheatengine-sdk-lua-bridge.dll`, SHA-256 `b008c8d8c136187f241542e6223dc0831999d8300dc2c4c01e1cf49f6fba7698` | +| Client qualification | `NotExecuted` for this Client tuple until Client qualification receipts exist for it (there is no separate qualification documentation tree; receipts, when they exist, are test-owned data under the relevant `*.Repository.Tests` project) | + +Never edit an installed Cheat Engine to match this profile: its runtime configuration applies to every managed plugin of +the installation, and CheatEngine.Client never treats such an edit as a setup step. A stock installation is not a +qualified profile, and a result on this profile authorizes no x86 or ARM64 plugin claim. + +## Versioning and compatibility + +CheatEngine.Client follows [Semantic Versioning 2.0.0](https://semver.org/) from 1.0.0. Every Client package is +released with the same version; use one version for all of them. -## How it helps improve CheatEngine.Client +The seven packages ship in lockstep. Each Client package depends on the Client packages it builds on at exactly its own +version (`[X.Y.Z]` in its nuspec, not the `X.Y.Z` minimum NuGet writes by default), because +`CheatEngine.Client.Extensions.DependencyInjection` and `CheatEngine.Client.Hosting` use internal types of +`CheatEngine.Client.Core` and of `CheatEngine.Client.Extensions.DependencyInjection`, which no public API baseline +protects. Reference `CheatEngine.Client` and let it bring the others; a Client package you reference directly takes the +same version. `CheatEngine.Client.Core` is not a standalone package: it has no public API and is published only as a +dependency of `CheatEngine.Client.Extensions.DependencyInjection`. -The package gives plugin authors a small, intentional composition boundary without exposing implementation or SDK -ownership types. It brings together: +- **Patch releases (1.0.x)** fix behavior and documentation without changing the public API. +- **Minor releases (1.x)** add API without breaking code compiled against an earlier 1.x: + - new types, members, overloads and namespaces; + - new members on a **call-only** interface. The documentation of every public interface says whether it is + *Call-only* (the Client implements it and applications call it, for example `ICheatEngineClient`, `IMemoryClient` + or `ICheatEngineLease`) or *Implementable* (applications implement it and the Client calls it). Implement a + call-only interface only in a test double, and expect to update the double in a minor release; + - new enum values. Public enums are `int` enums whose explicit values never change meaning. An outcome enum (a name + ending in `Kind`, `Status`, `State`, `Effect` or `Scope`) has `Unknown = 0`: handle a value you do not recognize + like `Unknown`. An option enum has a valid default at 0 and never an outcome suffix. +- **Frozen for all of 1.x:** the *Implementable* interfaces (`ILuaModule`, `ILuaOperation`, + `ILuaResultMapper`, `IMemoryCodec` and `ICheatEngineClientModule`) never gain, lose or change a + member. +- **Experimental APIs**, marked `[Experimental("CECLIENT500x")]`, can change or be removed in a minor release until + their live qualification passes; using one is an explicit opt-in to that diagnostic. +- **Charter:** the public API charter of the `CheatEngine.Client.Abstractions` README fixes the Try and throwing forms, + the names, the exception policy (no Client exception has a public constructor; `CheatEngineFailure.Throw` and + `ToException` create them) and the CheatEngine.SDK value types a public signature may use; 1.x only adds to it. +- **Not contractual:** the text of `CheatEngineFailure.Message`, of `CheatEngineFailure.Operation` and of exception + messages. Classify a failure by `CheatEngineFailure.Kind` and `HostEffect`, never by text. +- **CheatEngine.SDK:** Client 1.x depends on CheatEngine.SDK `[2.0.0, 3.0.0)`. Its descriptive value types (`Address`, + `PointerSize`, `ModuleInfo` and the others the charter lists) are part of the Client's public signatures, so a new + CheatEngine.SDK major version means a new Client major version, never a Client minor release. +- Removing or changing a stable public member, or changing the meaning of a value, happens only in a new major version. -- `CheatEngine.Client.Hosting` for the enable-epoch DI container and plugin lifecycle; -- `CheatEngine.Client.Fluent` for immutable memory and AOB request builders; -- `CheatEngine.Client.Core`, composed through Hosting, as the only SDK mapper; -- functional public namespaces such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, and `.Lua`. +The package contains no assembly of its own: its public types come from the Abstractions, +Extensions.DependencyInjection, Hosting and Fluent assemblies it brings. They live in the root namespace +`CheatEngine.Client` (`ICheatEngineClient`, `ICheatEngineLease`) and in functional namespaces such as +`CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, `.Processes`, `.Runtime`, `.Hosting` and +`.Extensions.DependencyInjection`. -Use the generated `ceplugin` template for a complete, buildable plugin shape. The client and all Client-created -resources are valid only for one enable epoch; do not retain them across disable/re-enable. See the repository -[README](../../README.md) for installation and deployment -guidance, [ADR 0001](../../docs/adr/0001-layered-in-process-architecture.md) -for the package architecture, and [ADR 0002](../../docs/adr/0002-plugin-activation-lifecycle.md) for lifecycle rules. +## Documentation -## Rules +| Package | Read it for | +|---|---| +| [`CheatEngine.Client.Hosting`](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Hosting/README.md) | The plugin base class, the per-enable provider, logging, deployment and every `CECLIENT` build diagnostic | +| [`CheatEngine.Client.Extensions.DependencyInjection`](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md) | `builder.Client`, options, opt-ins, memory codecs and memory budgets | +| [`CheatEngine.Client.Fluent`](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Fluent/README.md) | `Aob(...)`, `At(...)` and `Batch()` builders | +| [`CheatEngine.Client.Abstractions`](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md) | Every contract, failure and limit, the experimental APIs and the public API charter | +| [`CheatEngine.Client.Core`](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Core/README.md) | The diagnostic events and the cost of Cheat Engine calls | +| [`CheatEngine.Client.Templates`](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/templates/CheatEngine.Client.Templates/README.md) | `dotnet new ceplugin` | -- This is the only public package where Hosting, Core, and Fluent meet. -- The assembly and root namespace are both `CheatEngine.Client`; do not declare a `CheatEngine` or `Client` type in - this namespace because CA1724 matches each namespace segment. +Changes are listed in the [CHANGELOG](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/CHANGELOG.md). +Report vulnerabilities privately, as +[SECURITY.md](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/SECURITY.md) describes. Use the Client +only on local processes you are authorized to inspect or modify. diff --git a/src/CheatEngine.Client/packages.lock.json b/src/CheatEngine.Client/packages.lock.json index 09c60e9..96eb611 100644 --- a/src/CheatEngine.Client/packages.lock.json +++ b/src/CheatEngine.Client/packages.lock.json @@ -14,24 +14,17 @@ "resolved": "10.0.12", "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, - "Microsoft.SourceLink.GitHub": { + "Microsoft.Sbom.Targets": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } + "requested": "[4.1.13, )", + "resolved": "4.1.13", + "contentHash": "l9NiCqVmBBY06Lrxv61xWtiLvU1feto6j7QmMsaARBopO+QLTFDFLEIbYe8pYGjk1INJ2eWE/GGjToqQpokYAw==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" }, "Microsoft.Extensions.Configuration": { "type": "Transitive", @@ -56,34 +49,24 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -94,23 +77,23 @@ "cheatengine.client.fluent": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )" + "CheatEngine.Client.Abstractions": "[1.0.0, )" } }, "cheatengine.client.hosting": { "type": "Project", "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive", @@ -191,4 +174,4 @@ } } } -} +} \ No newline at end of file diff --git a/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj b/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj index cdf4ff6..d6966c2 100644 --- a/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj +++ b/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj @@ -4,12 +4,121 @@ Template false $(NoWarn);NU5128 + + true + dotnet new template for an in-process, dependency-injection-first Cheat Engine plugin built on CheatEngine.Client and CheatEngine.SDK. + $(TargetsForTfmSpecificContentInPackage);_CheatEngineClientStampTemplateProject - + + + + + + + + + + + + + + + + + = 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; +string text = new UTF8Encoding(false).GetString(bytes, bom ? 3 : 0, bytes.Length - (bom ? 3 : 0)); +foreach (Microsoft.Build.Framework.ITaskItem package in Packages) +{ + string version = package.GetMetadata("Version"); + Regex reference = new Regex("( and receive a version to stamp, but has {1} such reference(s) and version '{2}'.", + package.ItemSpec, count, version); + continue; + } + + text = reference.Replace(text, match => match.Groups[1].Value + version + match.Groups[2].Value); +} + +if (!Log.HasLoggedErrors) +{ + Directory.CreateDirectory(Path.GetDirectoryName(DestinationFile)); + byte[] stamped = new UTF8Encoding(bom).GetPreamble(); + byte[] body = new UTF8Encoding(false).GetBytes(text); + using (FileStream stream = File.Create(DestinationFile)) + { + stream.Write(stamped, 0, stamped.Length); + stream.Write(body, 0, body.Length); + } +} + ]]> + + + + + + + <_CheatEngineClientTemplateProject>$(MSBuildProjectDirectory)/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj + <_CheatEngineClientStampedTemplateProject>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'template', 'CheatEngine.Plugin.csproj')) + <_CheatEngineClientConfigurationJsonVersion>@(PackageVersion->WithMetadataValue('Identity', 'Microsoft.Extensions.Configuration.Json')->'%(Version)') + + + <_CheatEngineClientTemplatePackage Remove="@(_CheatEngineClientTemplatePackage)"/> + <_CheatEngineClientTemplatePackage Include="CheatEngine.Client" Version="$(PackageVersion)"/> + <_CheatEngineClientTemplatePackage Include="CheatEngine.SDK" Version="$(CheatEngineSdkVersion)"/> + <_CheatEngineClientTemplatePackage Include="Microsoft.Extensions.Configuration.Json" Version="$(_CheatEngineClientConfigurationJsonVersion)"/> + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/CheatEngine.Client.Templates/README.md b/templates/CheatEngine.Client.Templates/README.md index 5f3892f..de1ca2f 100644 --- a/templates/CheatEngine.Client.Templates/README.md +++ b/templates/CheatEngine.Client.Templates/README.md @@ -23,13 +23,14 @@ contract is checked before compilation. ## How it helps improve CheatEngine.Client The template turns the intended consumption model into buildable source. It exercises activation-scoped DI, generated -SDK plugin bootstrap, explicit configuration, bounded AOB probing, typed memory access, Address List inspection, and -an application-owned Lua module. Keeping this path executable prevents package, bootstrap, and documentation drift. +SDK plugin bootstrap, explicit configuration, AOB probing with a bounded copy (post-filtered global scan), typed memory +access, Address List inspection, and an application-owned Lua module. Keeping this path executable prevents package, +bootstrap, and documentation drift. It deliberately does not imply that a Native AOT binary is loadable by Cheat Engine. The project enables AOT compatibility analysis for the library graph, but a plugin must be deployed as the complete managed output required by -the SDK. Value scans remain capability-gated until their full Cheat Engine 7.7 x64 lifecycle has passed the opt-in -live gate. +the SDK. Value scans remain experimental (`CECLIENT5001`) until their full Cheat Engine 7.7 x64 lifecycle has passed +the opt-in live gate, so the template does not use them. ## Create a plugin @@ -37,24 +38,53 @@ Install the published template, then instantiate it from the directory that shou ```powershell dotnet new install CheatEngine.Client.Templates -dotnet new ceplugin --name Contoso.CheatEngine.Plugin --output .\Contoso.CheatEngine.Plugin -dotnet restore .\Contoso.CheatEngine.Plugin\Contoso.CheatEngine.Plugin.csproj +dotnet new ceplugin --name Contoso.CheatEngine.Plugin dotnet build .\Contoso.CheatEngine.Plugin\Contoso.CheatEngine.Plugin.csproj --configuration Release --no-restore ``` -The generated project's [README](content/CheatEngine.Plugin/README.md) explains the composition, configuration, and -managed deployment requirements. Keep both direct package references when adapting the project. - -## Validate the template from this repository - -Pack the repository first so the C# consumer smoke test can restore the Client packages from `artifacts/packages`: - -```powershell -dotnet pack CheatEngine.Client.slnx --configuration Release --output .\artifacts\packages -$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path .\artifacts\packages).Path -dotnet test --project .\tests\CheatEngine.Client.Tests\CheatEngine.Client.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on -``` - -The package smoke test installs the locally packed template, runs `dotnet new ceplugin --dry-run`, instantiates it into -a -temporary directory, restores it against the local package source, and builds it in Release configuration. +`dotnet new ceplugin --name ` creates the project in a `` folder (`--output` chooses another folder) with a +`.gitignore` for build output, and restores it unless `--no-restore` is passed. That restore writes +`packages.lock.json`, the exact package graph of the plugin with the content hash of each package, CheatEngine.SDK's +included: commit it, and restore with `--locked-mode` on a build machine. The lock also records the +`Microsoft.NET.ILLink.Tasks` version that the .NET SDK adds for `IsAotCompatible`, which changes with the SDK, a monthly +patch included: pin the exact .NET SDK in a `global.json` (`"rollForward": "disable"`) on every machine that restores +the plugin, or regenerate the lock with `dotnet restore --force-evaluate` after each SDK update. + +The template derives two names from the project name: + +- the name the plugin reports to Cheat Engine, the project name in printable ASCII (`Contoso.CheatEngine.Plugin`); +- its Lua status global, the project name in ASCII lower_snake_case followed by `_status` + (`contoso_cheat_engine_plugin_status`). + +A Lua global has one owner: the Client refuses to replace a global that another plugin registered. Different project +names can derive the same global, because the derivation lowercases the name and turns each camel-case boundary and +each run of other characters, non-ASCII letters included, into `_`: `MyPlugin` and `My.Plugin` both give +`my_plugin_status`, and a name without an ASCII letter or digit gives `plugin_status`. Rename the global in the +generated `Modules/PluginLuaFunctions.cs` when another plugin exports it. + +The generated project's +[README](https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md) +explains the composition, configuration, and managed deployment requirements. Keep both direct package references when +adapting the project. The generated project references the exact `CheatEngine.Client` version of this template package +and `CheatEngine.SDK` 2.0.0; keep `CheatEngine.SDK` on 2.x until a Client release says otherwise. A 3.x SDK fails the +build with `CECLIENT017`, and a version below 2.0.0 fails the restore with `NU1605`. + +## Supported host profile + +This Client release consumes CheatEngine.SDK 2.0.0 and names one Cheat Engine host profile, the qualifiable profile +that CheatEngine.SDK 2.0.0 names. A profile is what a qualification result can name; it is not itself a qualification +result. + +| Item | Value | +|---|---| +| Profile id | `ce-7.7.0.10621-x64-managed-hostfxr` | +| Host executable | `cheatengine-x86_64.exe` 7.7.0.10621, machine AMD64, SHA-256 `9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d`; not the `Cheat Engine.exe` launcher and not the `cheatengine-x86_64-SSE4-AVX2.exe` variant | +| Load profile | `managed-hostfxr`: the plugin is a framework-dependent .NET component started by Cheat Engine's nethost/hostfxr route | +| Runtime configuration | The qualification host's `ce.runtimeconfig.json` (`net10.0`), SHA-256 `68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b`, is a local modification, not an installer baseline | +| Consumed SDK package | `CheatEngine.SDK` 2.0.0, source commit `325c47b573f8bd39a247f1d0101f110fa36c1696`, NuGet content hash (SHA-512, base64) `NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==` | +| SDK native bridge | `build/native/cheatengine-sdk-lua-bridge.dll`, SHA-256 `b008c8d8c136187f241542e6223dc0831999d8300dc2c4c01e1cf49f6fba7698` | +| Client qualification | `NotExecuted` for this Client tuple until Client qualification receipts exist for it (there is no separate qualification documentation tree; receipts, when they exist, are test-owned data under the relevant `*.Repository.Tests` project) | + +Never edit an installed Cheat Engine to match this profile: its runtime configuration applies to every managed plugin of +the installation, and CheatEngine.Client never treats such an edit as a setup step. A stock installation is not a +qualified profile, and a result on this profile authorizes no x86 or ARM64 plugin claim. diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.gitignore b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.gitignore new file mode 100644 index 0000000..7a40e54 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.gitignore @@ -0,0 +1,9 @@ +# Build output. Keep packages.lock.json under version control: it records every resolved package of the plugin, +# CheatEngine.SDK's content hash included. +bin/ +obj/ + +# IDE state. +.vs/ +.idea/ +*.user diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/dotnetcli.host.json b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/dotnetcli.host.json new file mode 100644 index 0000000..c17d7ad --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/dotnetcli.host.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/dotnetcli.host", + "symbolInfo": { + "skipRestore": { + "longName": "no-restore", + "shortName": "" + } + } +} diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json index 7fe9c26..fdd4712 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json @@ -11,6 +11,7 @@ "shortName": "ceplugin", "sourceName": "CheatEngine.Plugin", "defaultName": "CheatEngine.Plugin", + "preferNameDirectory": true, "tags": { "language": "C#", "type": "project" @@ -29,6 +30,91 @@ "args": "[10.0.401,)" } }, + "symbols": { + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "displayName": "Skip restore", + "description": "If specified, skips the automatic restore of the project on create.", + "defaultValue": "false" + }, + "pluginDisplayName": { + "type": "derived", + "valueSource": "name", + "valueTransform": "pluginDisplayName", + "replaces": "CheatEngine Client Plugin" + }, + "luaGlobalPrefix": { + "type": "derived", + "valueSource": "name", + "valueTransform": "luaGlobalPrefix", + "replaces": "cheatengine_client_plugin" + } + }, + "forms": { + "pluginDisplayName": { + "identifier": "chain", + "steps": [ + "displayNamePrintableAscii", + "displayNameFallback" + ] + }, + "displayNamePrintableAscii": { + "identifier": "replace", + "pattern": "(?:[^ -~]|[\"\\\\])+", + "replacement": "_" + }, + "displayNameFallback": { + "identifier": "replace", + "pattern": "^[^A-Za-z0-9]*$", + "replacement": "Plugin" + }, + "luaGlobalPrefix": { + "identifier": "chain", + "steps": [ + "luaWordBoundary", + "luaAcronymBoundary", + "luaLowerCase", + "luaSeparator", + "luaTrim", + "luaEmpty", + "luaLeadingDigit" + ] + }, + "luaWordBoundary": { + "identifier": "replace", + "pattern": "([a-z0-9])([A-Z])", + "replacement": "$1_$2" + }, + "luaAcronymBoundary": { + "identifier": "replace", + "pattern": "([A-Z])([A-Z][a-z])", + "replacement": "$1_$2" + }, + "luaLowerCase": { + "identifier": "lowerCaseInvariant" + }, + "luaSeparator": { + "identifier": "replace", + "pattern": "[^a-z0-9]+", + "replacement": "_" + }, + "luaTrim": { + "identifier": "replace", + "pattern": "^_+|_+$", + "replacement": "" + }, + "luaEmpty": { + "identifier": "replace", + "pattern": "^$", + "replacement": "plugin" + }, + "luaLeadingDigit": { + "identifier": "replace", + "pattern": "^(?=[0-9])", + "replacement": "plugin_" + } + }, "primaryOutputs": [ { "path": "CheatEngine.Plugin.csproj" @@ -36,6 +122,8 @@ ], "postActions": [ { + "id": "restore", + "condition": "(!skipRestore)", "description": "Restore NuGet packages.", "manualInstructions": [ { diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj index 53f7116..3eefb65 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj @@ -13,11 +13,22 @@ true false + + true + - - + + diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs index c3f084e..f94da19 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs @@ -5,7 +5,6 @@ using CheatEngine.Client.Processes; using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Tables; using CheatEngine.SDK.Engine.Values; using Microsoft.Extensions.Logging; @@ -14,10 +13,10 @@ namespace CheatEngine.Plugin.Modules; /// -/// Demonstrates DI, options, bounded AOB/memory access, Address List snapshots, and generated Lua exports. +/// Demonstrates DI, options, materialization-bounded AOB and typed memory access, the Address List record count, +/// and generated Lua exports. /// internal sealed partial class PluginClientModule( - IMemoryCodec int32Codec, IOptions options, ILogger logger) : ICheatEngineClientModule { @@ -28,33 +27,43 @@ public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - int allowedTableRootCount = _options.AllowedTableRoots?.Length ?? 0; + int allowedTableRootCount = _options.AllowedTableRoots.Count; LogEnabled(logger, client.Epoch, allowedTableRootCount); - if (client.Tables.TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure tableFailure)) + if (client.Tables.TryGetRecordCount(out int recordCount, out CheatEngineFailure tableFailure)) { - LogAddressList(logger, table.RecordCount); + LogAddressList(logger, recordCount); } else { LogSkipped("Address List", tableFailure); } - if (!client.Processes.TryGetCurrent(out ProcessSnapshot process, out CheatEngineFailure processFailure)) + if (!client.Processes.TryGetCurrentProcess(out ProcessSnapshot process, out CheatEngineFailure processFailure)) { LogSkipped("AOB/memory probe", processFailure); return; } + // Cost and order: InModule keeps only matches that lie entirely inside the module, on every target. On a local + // target it limits Cheat Engine's scan to the module (a bounded MemScan that blocks Cheat Engine's main thread); + // on a CEServer or file-as-process target Cheat Engine scans the whole target and Client applies the module + // while copying. FirstOrNone copies one address but never stops Cheat Engine early. "First" is Cheat Engine's + // result-list order, which is not specified: it is not guaranteed to be the lowest address. A cancellation token + // cannot interrupt a scan that has started. AobScanBuilder scan = client.Patterns.Aob("48 8B ?? ?? ?? 89"); if (process.Name is { } processName) { scan = scan.InModule(processName); } - if (!scan.ReadableExecutable() - .FirstOrNone() - .TryExecute(out Address? address, out CheatEngineFailure scanFailure)) + // A scan that finds no match inside the module returns null: a factual zero on the bounded route, or a global + // result list without an in-module address. When a global scan returns no result list at all, it fails with + // IndeterminateHostResult, because that route cannot tell zero matches from a host failure, so it is logged as a + // skipped probe, never treated as "not found". + if (!scan.Executable() + .FirstOrNone() + .TryExecute(out Address? address, out CheatEngineFailure scanFailure)) { LogSkipped("AOB probe", scanFailure); return; @@ -65,9 +74,12 @@ public void OnEnabled(ICheatEngineClient client) return; } - if (client.Memory.At(match + 0x14).TryReadWith(int32Codec, out _, out CheatEngineFailure readFailure)) + // A built-in primitive needs no codec. A custom type reads through a codec registered as an application service + // and passed with each request (MemoryReadRequest); the Client never resolves a codec implicitly. + if (client.Memory.At(match + 0x14).TryRead(out _, out CheatEngineFailure readFailure)) { - LogMemoryReadSucceeded(logger, match); + // Addresses and values are user data: this default log records only that the probe succeeded. + LogMemoryReadSucceeded(logger); } else { @@ -81,28 +93,35 @@ public void OnDisabling(ICheatEngineClient client) ArgumentNullException.ThrowIfNull(client); } - /// Writes a bounded Client operation failure without exposing target-memory data. - private void LogSkipped(string operation, CheatEngineFailure failure) + /// Writes a classified Client failure without exposing user data. + /// + /// Only , and + /// are logged. and + /// can contain addresses, expressions, paths, or Lua text; log them only + /// behind an explicit opt-in chosen by your application. + /// + private void LogSkipped(string probe, CheatEngineFailure failure) { - LogClientFailure(logger, operation, failure); + LogClientFailure(logger, probe, failure.Kind, failure.Operation, failure.HostEffect); } /// Logs the activation epoch and configured count of trusted table-file roots. [LoggerMessage(Level = LogLevel.Information, Message = "CheatEngine.Plugin enabled at epoch {Epoch}; " + - "configured trusted table-file root count is {AllowedTableRootCount}.")] + "configured trusted table-file root count is {AllowedTableRootCount}.")] private static partial void LogEnabled(ILogger logger, long epoch, int allowedTableRootCount); - /// Logs the number of records in the current Address List snapshot. + /// Logs the number of top-level records in the current Address List. [LoggerMessage(Level = LogLevel.Information, Message = "Current Address List contains {RecordCount} record(s).")] private static partial void LogAddressList(ILogger logger, int recordCount); - /// Logs a successful bounded Int32 memory probe without logging the value read. - [LoggerMessage(Level = LogLevel.Information, - Message = "A typed Int32 memory read succeeded near AOB match {Address}.")] - private static partial void LogMemoryReadSucceeded(ILogger logger, Address address); + /// Logs a successful bounded Int32 memory probe without logging the address or the value read. + [LoggerMessage(Level = LogLevel.Information, Message = "A typed Int32 memory read near the AOB match succeeded.")] + private static partial void LogMemoryReadSucceeded(ILogger logger); - /// Logs a classified Client failure for an optional demonstration operation. - [LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {Operation}: {Reason}")] - private static partial void LogClientFailure(ILogger logger, string operation, CheatEngineFailure reason); + /// Logs the safe fields of a classified Client failure for an optional demonstration operation. + [LoggerMessage(Level = LogLevel.Debug, + Message = "Skipped {Probe}: {FailureKind} in {FailedOperation} (host effect: {HostEffect}).")] + private static partial void LogClientFailure(ILogger logger, string probe, CheatEngineFailureKind failureKind, + string failedOperation, CheatEngineHostEffect hostEffect); } diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs index 63a5825..c93d325 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs @@ -3,6 +3,12 @@ namespace CheatEngine.Plugin.Modules; /// Generated SDK Lua exports available while this plugin activation is enabled. +/// +/// A Lua global has one owner in Cheat Engine. dotnet new ceplugin derives the status global from the +/// project name (ASCII lower_snake_case, then _status), but different names can derive the same one: +/// MyPlugin and My.Plugin both give my_plugin_status. Rename the global when another +/// plugin exports it. +/// internal static partial class PluginLuaFunctions { /// Returns the activation-local status text for a generated Lua export. diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs index d7943e9..39ec65e 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs @@ -2,30 +2,36 @@ using CheatEngine.Client.Hosting; using CheatEngine.Plugin.Modules; using CheatEngine.SDK.Annotations.Plugin; + using Microsoft.Extensions.Configuration; namespace CheatEngine.Plugin; /// The plugin entry point generated and loaded by Cheat Engine. +/// +/// dotnet new ceplugin writes the project name, in printable ASCII, as the name the plugin reports to Cheat +/// Engine. +/// [CheatEnginePlugin("CheatEngine Client Plugin")] public sealed class Plugin : CheatEngineClientPlugin { - /// Adds explicit configuration sources for this activation. - protected override void Configure(CheatEnginePluginBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.Configuration - .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + /// Adds explicit configuration sources for this activation. + protected override void Configure(CheatEnginePluginBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + // appsettings.json is deployed next to the plugin assembly, not in Cheat Engine's folder. + builder.Configuration + .SetBasePath(builder.PluginDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); - builder.Client - .AddLuaModule() - .AddModule(); - } + builder.Client + .AddLuaModule() + .AddModule(); + } - /// Runs after the Client activation scope and its modules have started. - protected override void OnClientEnabled(ICheatEngineClient client) - { - ArgumentNullException.ThrowIfNull(client); - } + /// Runs after the Client activation scope and its modules have started. + protected override void OnClientEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + } } diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md index 48c46bc..0d7091a 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md @@ -13,13 +13,24 @@ this project inside the template instead of maintaining a separate `samples/` co The project provides a minimal but production-shaped plugin boundary: - `[CheatEnginePlugin]` is the SDK entry-point annotation recognized by the generated bootstrap. -- `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle. The Client graph, - options, and codecs are provider-local singletons; the module scope is the one scope inside that new provider, not a +- `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle. The Client graph + and options are provider-local singletons; the module scope is the one scope inside that new provider, not a persistent root that can be reused for a later enable. -- `Configure` explicitly loads the optional `appsettings.json` beside the plugin with `reloadOnChange: false` and - registers the generated `PluginLuaModule` through `AddLuaModule()`, then the application module. -- `PluginClientModule` demonstrates options, logging, a bounded AOB request, typed memory access, an Address List - snapshot, and normal Client module lifecycle callbacks. +- `Configure` explicitly loads the optional `appsettings.json` from `builder.PluginDirectory`, the folder of the plugin + assembly, with `reloadOnChange: false`, and registers the generated `PluginLuaModule` through + `AddLuaModule()`, then the application module. `AppContext.BaseDirectory` is not used: it describes + the Cheat Engine process that hosts .NET, not the plugin's deployment folder. +- The plugin reports `CheatEngine Client Plugin` to Cheat Engine and exports the Lua status global + `cheatengine_client_plugin_status`. `dotnet new ceplugin` derives both from the project name: the reported name is + the project name in printable ASCII, and the global is the project name in ASCII lower_snake_case followed by + `_status`. A Lua global has one owner: the Client refuses to replace a global that another plugin registered. + Different project names can derive the same global, because the derivation lowercases the name and turns each + camel-case boundary and each run of other characters, non-ASCII letters included, into `_`: `MyPlugin` and + `My.Plugin` both give `my_plugin_status`, and a name without an ASCII letter or digit gives `plugin_status`. Rename + the global in `Modules/PluginLuaFunctions.cs` when another plugin exports it. +- `PluginClientModule` demonstrates options, logging, a materialization-bounded AOB request (the module filter is + applied after a global scan), typed memory access, the Address List record count, and normal Client module lifecycle + callbacks. The project references `CheatEngine.Client` **and** `CheatEngine.SDK` directly. The SDK reference must remain direct: its plugin generator and native Lua bridge assets are build inputs, not a transitive implementation detail. @@ -30,15 +41,16 @@ manual bootstrap and explicitly set `CheatEngineClientManualBootstrap=true`. ## How it helps improve CheatEngine.Client -This plugin is compiled by the template smoke test. It therefore continuously verifies the installation path that -matters to consumers: package restore, SDK-generated bootstrap, copied bridge assets, functional Client namespaces, -and the DI-first lifecycle. Its normal host preconditions use `Try...` APIs, so an absent process or pattern does not -turn the example into an artificial activation failure. +This plugin is compiled by the template smoke test, which also builds an instance with warnings as errors under the +CheatEngine.Client repository's code style. It therefore continuously verifies the installation path that matters to +consumers: package restore and its lock file, SDK-generated bootstrap, copied bridge assets, functional Client +namespaces, and the DI-first lifecycle. Its normal host preconditions use `Try...` APIs, so an absent process or +pattern does not turn the example into an artificial activation failure. `PluginLuaModule` is an attribute-only declaration. The Client generator emits the activation-scoped implementation that acquires Lua state and invokes the generated SDK registration calls; application code contains neither those calls -nor raw Lua state or SDK ownership handles. The project does not demonstrate value scans because their complete -Create/Scan/Destroy lifecycle is still capability-gated pending the opt-in Cheat Engine 7.7 x64 live validation. +nor raw Lua state or SDK ownership handles. The project does not demonstrate value scans because their API stays +experimental (`CECLIENT5001`) pending the opt-in Cheat Engine 7.7 x64 live validation of their lifecycle. ## Build @@ -49,6 +61,18 @@ dotnet restore .\CheatEngine.Plugin.csproj dotnet build .\CheatEngine.Plugin.csproj --configuration Release --no-restore ``` +`dotnet new ceplugin` already restores the project, unless `--no-restore` is passed. The first restore writes +`packages.lock.json` (`RestorePackagesWithLockFile`): the exact package graph of the plugin and the content hash of each +package, CheatEngine.SDK's included. Commit it with the project; the generated `.gitignore` excludes only build output +and IDE state. On a build machine, restore with `dotnet restore .\CheatEngine.Plugin.csproj --locked-mode`, so that a +changed package graph fails the restore instead of changing the plugin. + +The lock also records `Microsoft.NET.ILLink.Tasks`, which the .NET SDK adds for `IsAotCompatible` at the version it +bundles, so a locked restore also fails (`NU1004`) after a .NET SDK update, a monthly patch included, that bundles +another version. Pin the exact .NET SDK in a `global.json` (`"rollForward": "disable"`) on every machine that restores +the plugin, or regenerate the lock with `dotnet restore .\CheatEngine.Plugin.csproj --force-evaluate` after each SDK +update and commit it. + Deploy the complete `bin\Release\net10.0` managed output produced by that build, including the plugin assembly, `.runtimeconfig.json`, `CheatEngine.SDK` assemblies, and the SDK Lua bridge assets. Do not publish this project as a Native AOT plugin binary: `IsAotCompatible` validates library compatibility only and is not a Cheat Engine plugin @@ -65,6 +89,26 @@ The opt-in target validates and stages the managed closure before individually r write-through Windows replacement semantics. It never changes a Cheat Engine installation, configuration, or plugin list. Because Windows cannot transactionally swap a non-empty directory, run it only while the plugin is disabled. +## Supported host profile + +This Client release consumes CheatEngine.SDK 2.0.0 and names one Cheat Engine host profile, the qualifiable profile +that CheatEngine.SDK 2.0.0 names. A profile is what a qualification result can name; it is not itself a qualification +result. + +| Item | Value | +|---|---| +| Profile id | `ce-7.7.0.10621-x64-managed-hostfxr` | +| Host executable | `cheatengine-x86_64.exe` 7.7.0.10621, machine AMD64, SHA-256 `9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d`; not the `Cheat Engine.exe` launcher and not the `cheatengine-x86_64-SSE4-AVX2.exe` variant | +| Load profile | `managed-hostfxr`: the plugin is a framework-dependent .NET component started by Cheat Engine's nethost/hostfxr route | +| Runtime configuration | The qualification host's `ce.runtimeconfig.json` (`net10.0`), SHA-256 `68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b`, is a local modification, not an installer baseline | +| Consumed SDK package | `CheatEngine.SDK` 2.0.0, source commit `325c47b573f8bd39a247f1d0101f110fa36c1696`, NuGet content hash (SHA-512, base64) `NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==` | +| SDK native bridge | `build/native/cheatengine-sdk-lua-bridge.dll`, SHA-256 `b008c8d8c136187f241542e6223dc0831999d8300dc2c4c01e1cf49f6fba7698` | +| Client qualification | `NotExecuted` for this Client tuple until Client qualification receipts exist for it (there is no separate qualification documentation tree; receipts, when they exist, are test-owned data under the relevant `*.Repository.Tests` project) | + +Never edit an installed Cheat Engine to match this profile: its runtime configuration applies to every managed plugin of +the installation, and CheatEngine.Client never treats such an edit as a setup step. A stock installation is not a +qualified profile, and a result on this profile authorizes no x86 or ARM64 plugin claim. + ## Configure and adapt `appsettings.json` is optional and is loaded only because `Plugin.Configure` explicitly adds it. Leave @@ -76,6 +120,30 @@ Let DI dispose objects that it creates. A module receives its disposable depende them; Hosting closes the activation scope and provider after module callbacks. Register a disposable implementation under one owning service descriptor, and use a non-disposable facade if the application needs a second service view. -Before deployment, replace the illustrative AOB pattern and offset in `Modules/PluginClientModule.cs`, and choose an -application-specific Lua global name in `Modules/PluginLuaFunctions.cs`. Keep AOB operations bounded and avoid logging -memory contents or Lua scripts by default. +Before deployment, replace the illustrative AOB pattern and offset in `Modules/PluginClientModule.cs`. The Lua global +in `Modules/PluginLuaFunctions.cs` is already derived from the project name; a global you add or rename must stay an +ASCII Lua identifier that no other plugin exports. Keep AOB copies bounded: `FirstOrNone` copies +one address, never stops Cheat Engine early, and follows Cheat Engine's unspecified result order. `InModule` keeps only +matches that lie entirely inside the module, on every target. On a qualified local target it limits Cheat Engine's scan +to the module: an exhaustive MemScan that blocks Cheat Engine's main thread while it runs, and whose "nothing found" is +a factual zero (`FirstOrNone` returns `null`). On a CEServer or file-as-process target Cheat Engine scans the whole +target and the Client applies the module while copying, so `InModule` does not reduce the scan's cost there; when +Cheat Engine returns no result list at all, the scan fails with `IndeterminateHostResult` (zero matches or a host +failure: that route cannot tell them apart), and the example treats it as a skipped probe. + +## Diagnostics and redaction + +The example logs only data that is safe by default: counts, the activation epoch, and a failure's `Kind`, `Operation`, +and `HostEffect`. It never logs addresses, values, symbol expressions, file paths, Lua source, or a failure's `Message` +or `Exception`, because those are user data. If your application needs them for troubleshooting, add a separate log +event behind an explicit, documented opt-in (for example a configuration flag that is off by default) instead of +changing the default events. + +Nothing the plugin logs is written anywhere until `Plugin.Configure` adds a logging provider, and the template adds +none. `builder.Logging.AddCheatEngineHostLog()` adds the opt-in provider that writes to CheatEngine.SDK's host log, +whose default sink is the Windows debug output of the Cheat Engine process, shown by an attached debugger or a +debug-output viewer. It writes each entry of level Information or higher (the default levels) as its category, event +id and message template: placeholder values and exception messages are never written. A message built by string +interpolation is its own template and carries its values, so log through constant templates or `LoggerMessage` +methods, as the example does. `AddCheatEngineHostLog(options => options.IncludeFormattedMessages = true)` writes +formatted messages and exceptions: use it only to troubleshoot on a machine you control. diff --git a/templates/CheatEngine.Client.Templates/packages.lock.json b/templates/CheatEngine.Client.Templates/packages.lock.json index 009cf0c..d7c5514 100644 --- a/templates/CheatEngine.Client.Templates/packages.lock.json +++ b/templates/CheatEngine.Client.Templates/packages.lock.json @@ -2,35 +2,24 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { + "Microsoft.Sbom.Targets": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } + "requested": "[4.1.13, )", + "resolved": "4.1.13", + "contentHash": "l9NiCqVmBBY06Lrxv61xWtiLvU1feto6j7QmMsaARBopO+QLTFDFLEIbYe8pYGjk1INJ2eWE/GGjToqQpokYAw==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", + "Microsoft.TemplateEngine.Authoring.Tasks": { + "type": "Direct", + "requested": "[10.0.401, )", "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + "contentHash": "YlT7YyzhPbEz60qYJFnYMutyIdqZGADNI0TICy1B87+iv6wKBZsz/9liI7yjHBctyLT8BpZ2l2BTc/3w36kcfA==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + "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/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs index 84c7d90..ee269d9 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs @@ -1,16 +1,30 @@ using CheatEngine.Client.Allocations; +using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Abstractions.Tests.Allocations; public sealed class AllocationContractsTests { [Fact] - public void RequestPreservesTheExactSizeAndAccess() + public void RequestPreservesTheExactSizeProtectionAndPreferredAddress() { - TargetAllocationRequest request = new(4096, TargetAllocationAccess.ExecuteReadWrite); + Address preferred = new(0x1_4000_0000); + + AllocationRequest request = new(4096, AllocationProtection.ExecuteReadWrite, preferred); Assert.Equal(4096, request.Size); - Assert.Equal(TargetAllocationAccess.ExecuteReadWrite, request.Access); + Assert.Equal(AllocationProtection.ExecuteReadWrite, request.Protection); + Assert.Equal(preferred, request.PreferredAddress); + } + + [Fact] + public void RequestDefaultsToReadWriteMemoryAnywhere() + { + AllocationRequest request = new(16); + + Assert.Equal(AllocationProtection.ReadWrite, request.Protection); + Assert.Null(request.PreferredAddress); + Assert.Equal(AllocationProtection.ReadWrite, default(AllocationProtection)); } [Theory] @@ -18,12 +32,31 @@ public void RequestPreservesTheExactSizeAndAccess() [InlineData(-1)] public void RequestRejectsANonPositiveSize(long size) { - Assert.Throws(() => new TargetAllocationRequest(size)); + Assert.Throws(() => new AllocationRequest(size)); + } + + [Fact] + public void RequestRejectsAnUndefinedProtection() + { + Assert.Throws(() => new AllocationRequest(1, (AllocationProtection) 99)); + } + + [Fact] + public void RequestRejectsTheNullAddressAsAPreference() + { + ArgumentOutOfRangeException exception = + Assert.Throws(() => new AllocationRequest(1, preferredAddress: Address.Zero)); + + Assert.Equal("preferredAddress", exception.ParamName); } [Fact] - public void RequestRejectsAnUndefinedAccess() + public void TheDefaultRequestHasNoSize() { - Assert.Throws(() => new TargetAllocationRequest(1, (TargetAllocationAccess) 99)); + AllocationRequest request = default; + + Assert.Equal(0, request.Size); + Assert.Equal(AllocationProtection.ReadWrite, request.Protection); + Assert.Null(request.PreferredAddress); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs index 8a59ec9..0502694 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs @@ -1,3 +1,9 @@ +#pragma warning disable CECLIENT5003 // The contract tests exercise the experimental instruction types. +#pragma warning disable CECLIENT5004 // The contract tests exercise the experimental Auto Assembler script type. + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + using CheatEngine.Client.Assembly; using CheatEngine.SDK.Engine.Values; @@ -5,26 +11,54 @@ namespace CheatEngine.Client.Abstractions.Tests.Assembly; public sealed class AssemblyContractsTests { + private const string InstructionDiagnosticId = "CECLIENT5003"; + + private const string UrlFormat = + "https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md#{0}"; + [Fact] - public void InstructionSnapshotCopiesTheCallerByteSpan() + public void InstructionSnapshotCopiesTheCallerByteSpanAndTheDisassemblerColumns() { - byte[] bytes = [0x90, 0x90]; - AssemblyInstructionSnapshot snapshot = new(0x401000, 2, "nop", bytes); + byte[] bytes = [0x8B, 0x45, 0x08]; + AssemblyInstructionSnapshot snapshot = new(0x401000, 3, "00401000", "mov eax,[ebp+08]", "", bytes); bytes[0] = 0xCC; Assert.Equal((Address) 0x401000, snapshot.Address); - Assert.Equal(2, snapshot.Length); - Assert.Equal("nop", snapshot.Text); - Assert.Equal([0x90, 0x90], snapshot.Bytes); + Assert.Equal(3, snapshot.Length); + Assert.Equal("00401000", snapshot.AddressText); + Assert.Equal("mov eax,[ebp+08]", snapshot.Opcode); + Assert.Equal(string.Empty, snapshot.Extra); + Assert.Equal("mov eax,[ebp+08]", snapshot.Text); + Assert.Equal([0x8B, 0x45, 0x08], snapshot.Bytes); + Assert.Equal(snapshot.Length, snapshot.Bytes.Length); + } + + [Theory] + [InlineData("", "call 00402000")] + [InlineData(" ", "call 00402000")] + [InlineData("->game.exe+2000", "call 00402000 ->game.exe+2000")] + public void InstructionTextJoinsTheOpcodeAndANonBlankAnnotation(string extra, string expected) + { + AssemblyInstructionSnapshot snapshot = new(0x401000, 5, "00401000", "call 00402000", extra, + [0xE8, 0xFB, 0x0F, 0x00, 0x00]); + + Assert.Equal(expected, snapshot.Text); + Assert.Equal(extra, snapshot.Extra); } [Fact] public void InstructionSnapshotRejectsEmptyAndMismatchedBytePayloads() { - Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 0, "nop", [0x90])); - Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 1, " ", [0x90])); - Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 1, "nop", [])); - Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 2, "nop", [0x90])); + Assert.Throws(() => + new AssemblyInstructionSnapshot(0x401000, 0, "00401000", "nop", "", [0x90])); + Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 1, "00401000", " ", "", [0x90])); + Assert.Throws(() => + new AssemblyInstructionSnapshot(0x401000, 1, null!, "nop", "", [0x90])); + Assert.Throws(() => + new AssemblyInstructionSnapshot(0x401000, 1, "00401000", "nop", null!, [0x90])); + Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 1, "00401000", "nop", "", [])); + Assert.Throws(() => + new AssemblyInstructionSnapshot(0x401000, 2, "00401000", "nop", "", [0x90])); } [Theory] @@ -36,12 +70,84 @@ public void InstructionRequestRejectsBlankSource(string source) } [Fact] - public void InstructionRequestPreservesTheAssemblyOriginAndSource() + public void InstructionRequestPreservesTheAssemblyOriginSourceAndDefaults() { AssemblyInstructionRequest request = new(0x401010, "mov eax, 1"); Assert.Equal((Address) 0x401010, request.Address); Assert.Equal("mov eax, 1", request.Instruction); + Assert.Equal(InstructionEncodingPreference.None, request.Preference); + Assert.False(request.SkipRangeCheck); + } + + [Fact] + public void InstructionRequestKeepsItsEncodingPreferenceAndRangeCheckOption() + { + AssemblyInstructionRequest request = new(0x401010, "jmp 00401100", InstructionEncodingPreference.Far, true); + + Assert.Equal(InstructionEncodingPreference.Far, request.Preference); + Assert.True(request.SkipRangeCheck); + } + + [Theory] + [InlineData(-1)] + [InlineData(4)] + public void InstructionRequestRejectsAnUndefinedEncodingPreference(int preference) + { + Assert.Throws(() => + new AssemblyInstructionRequest(0x401000, "nop", (InstructionEncodingPreference) preference)); + } + + [Fact] + public void EncodingPreferencesMirrorCheatEngineAssemblerPreferences() + { + (string Name, int Value)[] expected = + [ + (nameof(InstructionEncodingPreference.None), 0), (nameof(InstructionEncodingPreference.Short), 1), + (nameof(InstructionEncodingPreference.Long), 2), (nameof(InstructionEncodingPreference.Far), 3) + ]; + + Assert.Equal(expected, + Enum.GetValues().Select(static value => (value.ToString(), (int) value))); + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(InstructionEncodingPreference))); + } + + [Theory] + [InlineData(typeof(IAssemblyClient))] + [InlineData(typeof(AssemblyInstructionRequest))] + [InlineData(typeof(AssemblyInstructionSnapshot))] + [InlineData(typeof(InstructionEncodingPreference))] + public void EveryInstructionTypeIsExperimentalUnderItsDocumentedId(Type type) + { + ExperimentalAttribute experimental = Assert.Single(type.GetCustomAttributes()); + + Assert.Equal(InstructionDiagnosticId, experimental.DiagnosticId); + Assert.Equal(UrlFormat, experimental.UrlFormat); + } + + [Fact] + public void TheClientAssemblyPropertyIsExperimentalUnderTheInstructionId() + { + PropertyInfo property = typeof(ICheatEngineClient).GetProperty(nameof(ICheatEngineClient.Assembly))!; + + ExperimentalAttribute experimental = Assert.Single(property.GetCustomAttributes()); + + Assert.Equal(typeof(IAssemblyClient), property.PropertyType); + Assert.Equal(InstructionDiagnosticId, experimental.DiagnosticId); + Assert.Equal(UrlFormat, experimental.UrlFormat); + } + + [Fact] + public void TheInstructionClientOffersTheFourOperationFamiliesInTryAndThrowingForms() + { + string[] expected = + [ + "Assemble", "Disassemble", "GetInstructionLength", "GetPreviousInstructionAddress", "TryAssemble", + "TryDisassemble", "TryGetInstructionLength", "TryGetPreviousInstructionAddress" + ]; + + Assert.Equal(expected, + typeof(IAssemblyClient).GetMethods().Select(static method => method.Name).Order(StringComparer.Ordinal)); } [Fact] diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AutoAssemblerContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AutoAssemblerContractsTests.cs new file mode 100644 index 0000000..12cb7da --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AutoAssemblerContractsTests.cs @@ -0,0 +1,55 @@ +#pragma warning disable CECLIENT5003 // The contract tests compare the Auto Assembler surface with the instruction client. +#pragma warning disable CECLIENT5004 // The contract tests exercise the experimental Auto Assembler surface. + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using CheatEngine.Client.Assembly; + +namespace CheatEngine.Client.Abstractions.Tests.Assembly; + +/// The experimental Auto Assembler contracts (CECLIENT5004) and their redaction rules. +public sealed class AutoAssemblerContractsTests +{ + private const string DiagnosticId = "CECLIENT5004"; + + private const string UrlFormat = + "https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md#{0}"; + + [Fact] + public void ACheckResultKeepsTheVerdictAndNeverFormatsTheHostMessages() + { + AutoAssemblerCheckResult rejected = new(false, "Error in line 2: player_health", true); + AutoAssemblerCheckResult accepted = new(true, null, false); + + Assert.False(rejected.IsAccepted); + Assert.Equal("Error in line 2: player_health", rejected.HostMessages); + Assert.True(rejected.HostMessagesTruncated); + Assert.Equal("Rejected", rejected.ToString()); + Assert.Equal("Accepted", accepted.ToString()); + Assert.Throws(() => new AutoAssemblerCheckResult(false, null, true)); + } + + [Fact] + public void ThePatchLeaseIsAClientLeaseAndPatchesAreNotPartOfTheAssemblyClient() + { + Assert.True(typeof(ICheatEngineLease).IsAssignableFrom(typeof(IAutoAssemblerPatchLease))); + Assert.DoesNotContain(typeof(IAssemblyClient).GetMethods(), + static method => method.Name.Contains("Patch", StringComparison.Ordinal)); + Assert.DoesNotContain(typeof(ICheatEngineClient).GetProperties(), + static property => property.PropertyType == typeof(IAutoAssemblerClient)); + } + + [Theory] + [InlineData(typeof(IAutoAssemblerClient))] + [InlineData(typeof(IAutoAssemblerPatchLease))] + [InlineData(typeof(AutoAssemblerCheckResult))] + [InlineData(typeof(AutoAssemblerScript))] + public void EveryAutoAssemblerTypeIsExperimentalUnderItsDocumentedId(Type type) + { + ExperimentalAttribute experimental = Assert.Single(type.GetCustomAttributes()); + + Assert.Equal(DiagnosticId, experimental.DiagnosticId); + Assert.Equal(UrlFormat, experimental.UrlFormat); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj b/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj index 838a9af..e89c133 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj +++ b/tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj @@ -1,5 +1,10 @@ + + + $(NoWarn);CECLIENT5001;CECLIENT5002 + + diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs deleted file mode 100644 index 01885eb..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using CheatEngine.Client.Dbvm; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Abstractions.Tests.Dbvm; - -public sealed class DbvmContractsTests -{ - [Fact] - public void StatusPreservesAnExplicitUninitializedObservation() - { - DbvmStatusSnapshot status = new(DbvmState.NotInitialized, "7.7"); - - Assert.Equal(DbvmState.NotInitialized, status.State); - Assert.Equal("7.7", status.Version); - } - - [Fact] - public void StatusRejectsAnUndefinedState() - { - Assert.Throws(() => new DbvmStatusSnapshot((DbvmState) 99)); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void StatusRejectsAnEmptyVersionWhenOneIsProvided(string version) - { - Assert.Throws(() => new DbvmStatusSnapshot(DbvmState.Initialized, version)); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void WatchRequestAndEventRejectANonPositiveLength(int length) - { - Assert.Throws(() => new DbvmWatchRequest(0x600000, length)); - Assert.Throws(() => new DbvmWatchEvent(0x600000, length, DateTimeOffset.UtcNow)); - } - - [Fact] - public void InitializationRequestPreservesTheExplicitStealthChoice() - { - DbvmInitializationRequest request = new(true); - - Assert.True(request.UseStealthMode); - } - - [Fact] - public void WatchRequestAndEventPreserveTheirBoundedCopiedObservation() - { - DateTimeOffset occurredAt = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); - DbvmWatchRequest request = new(0x600000, 8); - DbvmWatchEvent watchEvent = new(0x600000, 8, occurredAt); - - Assert.Equal((Address) 0x600000, request.Address); - Assert.Equal(8, request.Length); - Assert.Equal((Address) 0x600000, watchEvent.Address); - Assert.Equal(8, watchEvent.Length); - Assert.Equal(occurredAt, watchEvent.OccurredAt); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs deleted file mode 100644 index 82d272d..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Immutable; - -using CheatEngine.Client.Debugger; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Abstractions.Tests.Debugger; - -public sealed class DebuggerContractsTests -{ - [Fact] - public void BreakpointRequestPreservesAddressAndKind() - { - BreakpointRequest request = new(0x401000, BreakpointKind.Access); - - Assert.Equal((Address) 0x401000, request.Address); - Assert.Equal(BreakpointKind.Access, request.Kind); - } - - [Fact] - public void BreakpointRequestRejectsAnUndefinedKind() - { - Assert.Throws(() => new BreakpointRequest(0x401000, (BreakpointKind) 99)); - } - - [Fact] - public void BreakpointEventRequiresInitializedCopiedRegisterSnapshots() - { - Assert.Throws(() => new BreakpointEvent(0x401000, 1, default)); - - ImmutableArray registers = [new("RAX", 42)]; - BreakpointEvent breakpointEvent = new(0x401000, 1, registers); - - Assert.Equal((Address) 0x401000, breakpointEvent.Address); - Assert.Equal(1, breakpointEvent.ThreadId); - Assert.Equal(registers, breakpointEvent.Registers); - } - - [Fact] - public void BreakpointEventRejectsANegativeThreadIdentifier() - { - Assert.Throws(() => new BreakpointEvent( - 0x401000, - -1, - ImmutableArray.Empty)); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void RegisterSnapshotRejectsABlankName(string name) - { - Assert.Throws(() => new BreakpointRegisterSnapshot(name, 0)); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/DefaultOutputValueTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/DefaultOutputValueTests.cs new file mode 100644 index 0000000..1dcd318 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/DefaultOutputValueTests.cs @@ -0,0 +1,159 @@ +using System.Collections.Immutable; +using System.Reflection; + +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Abstractions.Tests; + +/// +/// Every Client value type that a public member returns or a Try method publishes is safe to read at +/// : a failed Try leaves its output , so every property of +/// that value must still be readable, a non-nullable reference property is never , and an +/// property is never the default array. +/// +/// +/// The value types are collected by reflection: the return types and out parameters of every public member of +/// every public interface and class of the Abstractions assembly, then the Client value types their properties +/// expose, recursively. A generic value type is closed over . +/// +public sealed class DefaultOutputValueTests +{ + private const string ClientNamespace = "CheatEngine.Client"; + + private static readonly ReflectionAssembly Abstractions = typeof(ICheatEngineClient).Assembly; + + [Fact] + public void EveryPublishedClientValueIsSafeToReadAtItsDefault() + { + HashSet published = CollectPublishedValueTypes(); + NullabilityInfoContext nullability = new(); + List offenders = []; + foreach (Type type in published) + { + object value = Activator.CreateInstance(type)!; + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length != 0) + { + continue; + } + + object? read; + try + { + read = property.GetValue(value); + } + catch (TargetInvocationException exception) + { + offenders.Add($"{Describe(type)}.{property.Name} throws {exception.InnerException?.GetType().Name}"); + continue; + } + + if (IsImmutableArray(property.PropertyType)) + { + if ((bool) property.PropertyType.GetProperty(nameof(ImmutableArray<>.IsDefault))!.GetValue(read)!) + { + offenders.Add($"{Describe(type)}.{property.Name} is a default ImmutableArray"); + } + } + else if (!property.PropertyType.IsValueType && read is null && + nullability.Create(property).ReadState == NullabilityState.NotNull) + { + offenders.Add($"{Describe(type)}.{property.Name} is null although it is declared non-nullable"); + } + } + } + + // The collection cannot pass vacuously: the outputs of the main Try forms are among the checked types. + Assert.Contains(typeof(AobScanResult), published); + Assert.Contains(typeof(CheatEngineRuntimeSnapshot), published); + Assert.Contains(typeof(MemoryRecordContentSnapshot), published); + Assert.True(offenders.Count == 0, + "A Client output value must be safe to read at its default (empty text, empty arrays):" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + /// Collects the Client value types that public members return or publish, with those they expose. + private static HashSet CollectPublishedValueTypes() + { + HashSet found = []; + Queue pending = new(); + foreach (Type type in Abstractions.GetExportedTypes().Where(static type => type.IsInterface || type.IsClass)) + { + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + switch (member) + { + case PropertyInfo property: + Enqueue(property.PropertyType); + break; + case MethodInfo method when !method.IsSpecialName: + Enqueue(method.ReturnType); + foreach (ParameterInfo parameter in method.GetParameters().Where(static parameter => parameter.IsOut)) + { + Enqueue(parameter.ParameterType.GetElementType()!); + } + + break; + } + } + } + + while (pending.TryDequeue(out Type? type)) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + Enqueue(property.PropertyType); + } + } + + return found; + + void Enqueue(Type candidate) + { + Type type = Nullable.GetUnderlyingType(candidate) ?? candidate; + if (IsImmutableArray(type)) + { + Enqueue(type.GetGenericArguments()[0]); + return; + } + + if (!type.IsValueType || type.IsEnum || type.IsGenericParameter) + { + return; + } + + if (type.ContainsGenericParameters) + { + if (!type.IsGenericType) + { + return; + } + + type = type.GetGenericTypeDefinition().MakeGenericType( + [.. type.GetGenericArguments().Select(static _ => typeof(int))]); + } + + if (type.Namespace?.StartsWith(ClientNamespace, StringComparison.Ordinal) == true && + type.Assembly == Abstractions && found.Add(type)) + { + pending.Enqueue(type); + } + } + } + + private static bool IsImmutableArray(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ImmutableArray<>); + } + + private static string Describe(Type type) + { + return type.IsGenericType ? type.GetGenericTypeDefinition().Name + "" : type.Name; + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs deleted file mode 100644 index cf7b934..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Abstractions.Tests.Events; - -public sealed class EventStreamOptionsTests -{ - [Fact] - public void ConstructorUsesDropOldestByDefaultForAPositiveCapacity() - { - EventStreamOptions options = new(32); - - Assert.Equal(32, options.Capacity); - Assert.Equal(EventStreamOverflowPolicy.DropOldest, options.OverflowPolicy); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void ConstructorRejectsANonPositiveCapacity(int capacity) - { - Assert.Throws(() => new EventStreamOptions(capacity)); - } - - [Theory] - [InlineData(EventStreamOverflowPolicy.DropNewest)] - [InlineData(EventStreamOverflowPolicy.FailSubscription)] - public void ConstructorPreservesEachExplicitOverflowPolicy(EventStreamOverflowPolicy overflowPolicy) - { - EventStreamOptions options = new(1, overflowPolicy); - - Assert.Equal(overflowPolicy, options.OverflowPolicy); - } - - [Fact] - public void ConstructorRejectsAnUndefinedOverflowPolicy() - { - Assert.Throws(() => new EventStreamOptions(1, (EventStreamOverflowPolicy) 99)); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs deleted file mode 100644 index 428181d..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using CheatEngine.Client.Hashing; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Abstractions.Tests.Hashing; - -public sealed class HashingContractsTests -{ - [Fact] - public void MemoryHashRequestPreservesTheBoundedRangeAndAlgorithm() - { - MemoryHashRequest request = new(0x500000, 64, TargetHashAlgorithm.Sha1); - - Assert.Equal((Address) 0x500000, request.Address); - Assert.Equal(64, request.Length); - Assert.Equal(TargetHashAlgorithm.Sha1, request.Algorithm); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void MemoryHashRequestRejectsANonPositiveLength(int length) - { - Assert.Throws(() => new MemoryHashRequest(0x500000, length)); - } - - [Fact] - public void HashContractsRejectAnUndefinedAlgorithm() - { - TargetHashAlgorithm invalid = (TargetHashAlgorithm) 99; - - Assert.Throws(() => new MemoryHashRequest(0x500000, 1, invalid)); - Assert.Throws(() => new FileHashRequest( - Path.Combine(Path.GetTempPath(), "client-hash.bin"), invalid)); - Assert.Throws(() => new HashDigest(invalid, "digest")); - } - - [Fact] - public void FileHashRequestRequiresAnAbsolutePathButNotFileSystemAccessAtConstruction() - { - string path = Path.Combine(Path.GetTempPath(), "not-created.bin"); - FileHashRequest request = new(path); - - Assert.Equal(path, request.FilePath); - Assert.Equal(TargetHashAlgorithm.Sha256, request.Algorithm); - Assert.False(File.Exists(path)); - Assert.Throws(() => new FileHashRequest("relative.bin")); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void DigestRejectsBlankValues(string value) - { - Assert.Throws(() => new HashDigest(TargetHashAlgorithm.Sha256, value)); - } - - [Fact] - public void DigestPreservesAlgorithmAndCopiedText() - { - HashDigest digest = new(TargetHashAlgorithm.Md5, "a94a8fe5"); - - Assert.Equal(TargetHashAlgorithm.Md5, digest.Algorithm); - Assert.Equal("a94a8fe5", digest.Value); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs deleted file mode 100644 index 255fdc2..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using CheatEngine.Client.Hotkeys; - -namespace CheatEngine.Client.Abstractions.Tests.Hotkeys; - -public sealed class HotkeyContractsTests -{ - [Fact] - public void GesturePreservesTheVirtualKeyAndModifierCombination() - { - HotkeyGesture gesture = new(0x70, HotkeyModifiers.Control | HotkeyModifiers.Shift); - - Assert.Equal(0x70, gesture.VirtualKey); - Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Shift, gesture.Modifiers); - } - - [Theory] - [InlineData(0)] - [InlineData(255)] - public void GestureAcceptsBothDocumentedVirtualKeyBoundaries(int virtualKey) - { - HotkeyGesture gesture = new(virtualKey); - - Assert.Equal(virtualKey, gesture.VirtualKey); - } - - [Theory] - [InlineData(-1)] - [InlineData(256)] - public void GestureRejectsValuesOutsideTheVirtualKeyRange(int virtualKey) - { - Assert.Throws(() => new HotkeyGesture(virtualKey)); - } - - [Fact] - public void GestureRejectsUnknownModifierBits() - { - Assert.Throws(() => new HotkeyGesture(0x70, (HotkeyModifiers) 16)); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void RegistrationAndEventRejectBlankNames(string name) - { - HotkeyGesture gesture = new(0x70); - - Assert.Throws(() => new HotkeyRegistration(name, gesture)); - Assert.Throws(() => new HotkeyEvent(name, gesture, DateTimeOffset.UtcNow)); - } - - [Fact] - public void RegistrationAndEventPreserveCopiedGestureAndTimestamp() - { - HotkeyGesture gesture = new(0x71, HotkeyModifiers.Alt); - DateTimeOffset occurredAt = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); - HotkeyRegistration registration = new("plugin.toggle", gesture); - HotkeyEvent hotkeyEvent = new("plugin.toggle", gesture, occurredAt); - - Assert.Equal("plugin.toggle", registration.Name); - Assert.Equal(gesture, registration.Gesture); - Assert.Equal("plugin.toggle", hotkeyEvent.Name); - Assert.Equal(gesture, hotkeyEvent.Gesture); - Assert.Equal(occurredAt, hotkeyEvent.OccurredAt); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs index 411c8c0..43fb38e 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs @@ -57,7 +57,7 @@ public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() typeof(ILuaClient), typeof(ILuaModule), typeof(ILuaModuleLease), - typeof(IDescribedLuaModule), + typeof(LuaModuleReleaseOutcome), typeof(ILuaOperation<>), typeof(ILuaResultMapper<,>), typeof(ILuaExecutionContext), @@ -77,14 +77,14 @@ public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() || typeName.StartsWith("CheatEngine.SDK.Lua.References.LuaRef", StringComparison.Ordinal)); } - /// Infers an operation result through the Lua client interface and forwards its successful result. + /// Runs a class operation through the one generic pair of the Lua client interface. [Fact] - public void InterfaceExecutionInfersTheOperationResultTypeAndForwardsSuccess() + public void ClassOperationsRunThroughTheGenericPair() { ILuaClient client = new ForwardingLuaClient(); ConstantIntLuaOperation operation = new(42); - int executeResult = client.Execute(operation, TestContext.Current.CancellationToken); + int executeResult = client.Execute(operation, TestContext.Current.CancellationToken); bool tryExecuteSucceeded = client.TryExecute( operation, out int tryExecuteResult, @@ -98,15 +98,15 @@ public void InterfaceExecutionInfersTheOperationResultTypeAndForwardsSuccess() Assert.Equal(2, operation.ExecutionCount); } - /// Preserves the value-type operation overload for third-party Lua client implementations. + /// Runs a readonly value operation through the same generic pair, by reference. [Fact] - public void DefaultValueOperationOverloadsRemainCompatibleWithExistingImplementations() + public void ValueOperationsRunThroughTheSameGenericPair() { ILuaClient client = new ForwardingLuaClient(); StructIntLuaOperation operation = new(17); bool succeeded = client.TryExecute( - operation, + in operation, out int result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -114,7 +114,26 @@ public void DefaultValueOperationOverloadsRemainCompatibleWithExistingImplementa Assert.True(succeeded); Assert.Equal(17, result); Assert.Equal(default, failure); - Assert.Equal(17, client.Execute(operation, TestContext.Current.CancellationToken)); + Assert.Equal(17, client.Execute(in operation, TestContext.Current.CancellationToken)); + } + + /// The Lua client has exactly one generic execution pair, without interface-typed overloads. + [Fact] + public void TheLuaClientExposesOneGenericExecutionPair() + { + MethodInfo[] execute = + [ + .. typeof(ILuaClient).GetMethods() + .Where(static method => method.Name is nameof(ILuaClient.Execute) or nameof(ILuaClient.TryExecute)) + ]; + + Assert.Equal(2, execute.Length); + Assert.All(execute, static method => + { + Assert.Equal(2, method.GetGenericArguments().Length); + Assert.True(method.GetParameters()[0].IsIn); + Assert.False(method.IsVirtual && !method.IsAbstract); + }); } /// Uses static abstract mapper dispatch without reflection or an SDK handle in the result. @@ -127,13 +146,13 @@ public void LuaResultMapperUsesTheDeclaredStaticMapContract() private static IEnumerable GetPublicSignatureTypes(Type type) { foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.Static)) + BindingFlags.Static)) { yield return property.PropertyType; } foreach (MethodInfo method in - type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { yield return method.ReturnType; foreach (ParameterInfo parameter in method.GetParameters()) @@ -168,24 +187,27 @@ public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken ca throw new NotSupportedException("Module registration is outside this forwarding test double."); } - public bool TryExecute( - ILuaOperation operation, + public bool TryExecute( + in TOperation operation, [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation { - ArgumentNullException.ThrowIfNull(operation); return operation.TryExecute(_context, out result, out failure); } - public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) + public TResult Execute(in TOperation operation, + CancellationToken cancellationToken = default) + where TOperation : ILuaOperation { - if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken)) + if (TryExecute(in operation, out TResult? result, out CheatEngineFailure failure, + cancellationToken)) { return result!; } - failure.Throw(); + failure.Throw(cancellationToken); throw new InvalidOperationException("A failed Lua operation must throw its mapped exception."); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaModuleReleaseOutcomeTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaModuleReleaseOutcomeTests.cs new file mode 100644 index 0000000..c0076e9 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaModuleReleaseOutcomeTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Abstractions.Tests.Lua; + +/// +/// C1 contract of the handle-free Lua module release outcome (F12, Q16): the facts CheatEngine.SDK observed, in the +/// Client lease vocabulary, and the shapes of a release that cannot happen are refused. +/// +[Trait("Qualification", "Q16")] +public sealed class LuaModuleReleaseOutcomeTests +{ + [Fact] + public void ReleasedCarriesTheCountsTheSdkObserved() + { + LuaModuleReleaseOutcome outcome = LuaModuleReleaseOutcome.Released("plugin", 2, 0, 1); + + Assert.Equal("plugin", outcome.ModuleName); + Assert.Equal(LeaseReleaseKind.Released, outcome.Kind); + Assert.Equal(2, outcome.RemovedCount); + Assert.Equal(0, outcome.RestoredCount); + Assert.Equal(1, outcome.ReplacementCount); + Assert.Equal(0, outcome.RemainingCount); + Assert.Empty(outcome.FailedExports); + Assert.True(outcome.IsComplete); + } + + [Fact] + public void PartiallyReleasedNamesItsFailedExportsAsRemaining() + { + LuaModuleReleaseOutcome outcome = + LuaModuleReleaseOutcome.PartiallyReleased("plugin", 1, 0, 0, ["status", "ping"]); + + Assert.Equal(LeaseReleaseKind.PartiallyReleased, outcome.Kind); + Assert.Equal(["status", "ping"], outcome.FailedExports); + Assert.Equal(2, outcome.RemainingCount); + Assert.False(outcome.IsComplete); + } + + [Fact] + public void TheRemainingFactoriesReportWhatTheirKindMeans() + { + LuaModuleReleaseOutcome alreadyReleased = LuaModuleReleaseOutcome.AlreadyReleased("plugin"); + LuaModuleReleaseOutcome stale = LuaModuleReleaseOutcome.RefusedRuntimeChanged("plugin", 3); + LuaModuleReleaseOutcome unavailable = LuaModuleReleaseOutcome.CleanupUnavailable("plugin", 3); + + Assert.Equal(LeaseReleaseKind.AlreadyReleased, alreadyReleased.Kind); + Assert.True(alreadyReleased.IsComplete); + Assert.Equal(LeaseReleaseKind.RefusedRuntimeChanged, stale.Kind); + Assert.Equal(3, stale.RemainingCount); + Assert.False(stale.IsComplete); + Assert.Equal(LeaseReleaseKind.CleanupUnavailable, unavailable.Kind); + Assert.Equal(3, unavailable.RemainingCount); + Assert.False(unavailable.IsComplete); + } + + [Theory] + [InlineData(LeaseReleaseKind.Unknown, false)] + [InlineData(LeaseReleaseKind.Released, true)] + [InlineData(LeaseReleaseKind.AlreadyReleased, true)] + [InlineData(LeaseReleaseKind.Replaced, true)] + [InlineData(LeaseReleaseKind.Superseded, true)] + [InlineData(LeaseReleaseKind.ExternallyRemoved, true)] + [InlineData(LeaseReleaseKind.RefusedTargetNotAttached, false)] + [InlineData(LeaseReleaseKind.RefusedTargetChanged, false)] + [InlineData(LeaseReleaseKind.RefusedTargetIdentityUnavailable, false)] + [InlineData(LeaseReleaseKind.RefusedRuntimeChanged, false)] + [InlineData(LeaseReleaseKind.CleanupUnconfirmed, false)] + [InlineData(LeaseReleaseKind.CleanupUnavailable, false)] + public void IsCompleteOnlyForAKindThatLeavesNothingBehind(LeaseReleaseKind kind, bool expected) + { + LuaModuleReleaseOutcome outcome = LuaModuleReleaseOutcome.Create("plugin", kind, 0, 0, 0, 1, default); + + Assert.Equal(expected, outcome.IsComplete); + Assert.Empty(outcome.FailedExports); + } + + [Fact] + public void FailedExportsAreRequiredExactlyForAPartialRelease() + { + Assert.Throws(() => + LuaModuleReleaseOutcome.Create("plugin", LeaseReleaseKind.PartiallyReleased, 0, 0, 0, 0, [])); + Assert.Throws(() => + LuaModuleReleaseOutcome.Create("plugin", LeaseReleaseKind.Released, 0, 0, 0, 1, ["status"])); + } + + [Fact] + public void BlankOrDuplicateFailedExportsAreRejected() + { + Assert.Throws(() => + LuaModuleReleaseOutcome.PartiallyReleased("plugin", 0, 0, 0, [" "])); + Assert.Throws(() => + LuaModuleReleaseOutcome.PartiallyReleased("plugin", 0, 0, 0, ["status", "status"])); + } + + [Fact] + public void BlankModuleNamesUndefinedKindsNegativeCountsAndTooFewRemainingAreRejected() + { + Assert.Throws(() => LuaModuleReleaseOutcome.AlreadyReleased(" ")); + Assert.Throws(() => + LuaModuleReleaseOutcome.Create("plugin", (LeaseReleaseKind) 99, 0, 0, 0, 0, [])); + Assert.Throws(() => LuaModuleReleaseOutcome.Released("plugin", -1, 0, 0)); + Assert.Throws(() => LuaModuleReleaseOutcome.RefusedRuntimeChanged("plugin", -1)); + Assert.Throws(() => + LuaModuleReleaseOutcome.Create("plugin", LeaseReleaseKind.PartiallyReleased, 0, 0, 0, 1, ["status", "ping"])); + } + + [Fact] + public void FailedExportsAreCopiedAndImmutable() + { + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + builder.Add("status"); + LuaModuleReleaseOutcome outcome = LuaModuleReleaseOutcome.PartiallyReleased("plugin", 0, 0, 0, + builder.ToImmutable()); + + builder.Add("ping"); + + Assert.Equal(["status"], outcome.FailedExports); + } + + [Fact] + public void ToStringListsTheModuleKindCountsAndFailedExports() + { + LuaModuleReleaseOutcome outcome = + LuaModuleReleaseOutcome.PartiallyReleased("plugin", 1, 0, 2, ["status", "ping"]); + + Assert.Equal( + "Module=plugin; Kind=PartiallyReleased; Removed=1; Restored=0; Replacement=2; Remaining=2; Failed=status,ping", + outcome.ToString()); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs index add36e7..659bd65 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs @@ -49,7 +49,7 @@ public void BytesReadRequestPreservesTheExactPositiveLength() public void StringReadRequestRejectsANonPositiveMaximumLength(int maximumLength) { Assert.Throws(() => new MemoryStringReadRequest( - 0x401000, maximumLength, true)); + 0x401000, maximumLength, MemoryStringEncoding.Utf16)); } [Fact] @@ -57,11 +57,11 @@ public void StringReadRequestPreservesTheExplicitMaximumAndEncodingChoice() { Address address = 0x402000; - MemoryStringReadRequest request = new(address, 128, true); + MemoryStringReadRequest request = new MemoryStringReadRequest(address, 128, MemoryStringEncoding.Utf16); Assert.Equal(address, request.Address); Assert.Equal(128, request.MaximumLength); - Assert.True(request.WideCharacter); + Assert.Equal(MemoryStringEncoding.Utf16, request.Encoding); } [Fact] @@ -69,39 +69,45 @@ public void StringWriteRequestPreservesTextAndTheExplicitEncodingChoice() { Address address = 0x402100; - MemoryStringWriteRequest request = new(address, "Player one", true); + MemoryStringWriteRequest request = + new MemoryStringWriteRequest(address, "Player one", 10, MemoryStringEncoding.Utf16); Assert.Equal(address, request.Address); Assert.Equal("Player one", request.Value); - Assert.True(request.WideCharacter); + Assert.Equal(10, request.MaximumLength); + Assert.Equal(MemoryStringEncoding.Utf16, request.Encoding); } [Fact] public void StringWriteRequestRejectsNullText() { - Assert.Throws(() => new MemoryStringWriteRequest(0x402200, null!)); + Assert.Throws(() => + new MemoryStringWriteRequest(0x402200, null!, 1, MemoryStringEncoding.Utf8)); + Assert.Throws(() => + new MemoryStringWriteRequest(0x402200, "A", 0, MemoryStringEncoding.Utf8)); } [Fact] public void ExplicitStringFactoriesPreserveTheRequestedEncodingAndBound() { - MemoryStringReadRequest read = MemoryStringReadRequest.Create(0x402210, 64, MemoryStringEncoding.Utf16); - MemoryStringWriteRequest write = MemoryStringWriteRequest.CreateBounded(0x402220, "é", 2, + MemoryStringReadRequest read = new MemoryStringReadRequest(0x402210, 64, MemoryStringEncoding.Utf16); + MemoryStringWriteRequest write = new MemoryStringWriteRequest(0x402220, "é", 2, MemoryStringEncoding.Utf8); Assert.Equal(MemoryStringEncoding.Utf16, read.Encoding); - Assert.True(read.WideCharacter); + Assert.Equal(64, read.MaximumLength); Assert.Equal(MemoryStringEncoding.Utf8, write.Encoding); - Assert.False(write.WideCharacter); Assert.Equal(2, write.MaximumLength); } [Fact] public void ExplicitBoundedStringFactoryRejectsAnOverlongUtf8Payload() { - Assert.Throws(() => MemoryStringWriteRequest.CreateBounded(0x402230, "é", 1, + Assert.Throws(() => new MemoryStringWriteRequest(0x402230, "é", 1, MemoryStringEncoding.Utf8)); - Assert.Throws(() => MemoryStringReadRequest.Create(0x402240, 10, + Assert.Throws(() => new MemoryStringReadRequest(0x402240, 10, + (MemoryStringEncoding) 42)); + Assert.Throws(() => new MemoryStringWriteRequest(0x402240, "A", 10, (MemoryStringEncoding) 42)); } @@ -119,10 +125,10 @@ public void PrimitiveBatchRequestsCopyCallerInputsAndEnforceTheSharedBound() Assert.Equal([0x403000UL, 0x403010UL], reads.Addresses); Assert.Equal(0x403020UL, batchWrites.Values[0].Address); Assert.Equal(12, batchWrites.Values[0].Value); - Assert.Equal(MemoryBatchLimits.MaximumOperations, 1024); + Assert.Equal(MemoryBatchLimits.MaximumOperationCount, 1024); Assert.Throws(() => new MemoryPrimitiveBatchReadRequest(Array.Empty
())); Assert.Throws(() => new MemoryPrimitiveBatchWriteRequest( - new MemoryAddressValue[MemoryBatchLimits.MaximumOperations + 1])); + new MemoryAddressValue[MemoryBatchLimits.MaximumOperationCount + 1])); } [Fact] diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryResourceLimitsAndBatchOutcomeTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryResourceLimitsAndBatchOutcomeTests.cs index db06467..8d98872 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryResourceLimitsAndBatchOutcomeTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryResourceLimitsAndBatchOutcomeTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; + using CheatEngine.Client.Memory; using CheatEngine.Client.Results; @@ -14,7 +16,7 @@ public void DefaultLimitsPreserveTheDocumentedSafeBudgetsAndBatchHardCap() Assert.Equal(MemoryResourceLimits.DefaultMaximumWriteBytes, limits.MaximumWriteBytes); Assert.Equal(MemoryResourceLimits.DefaultMaximumStringBytes, limits.MaximumStringBytes); Assert.Equal(MemoryResourceLimits.DefaultMaximumBatchPayloadBytes, limits.MaximumBatchPayloadBytes); - Assert.Equal(MemoryBatchLimits.MaximumOperations, limits.MaximumBatchOperationCount); + Assert.Equal(MemoryBatchLimits.MaximumOperationCount, limits.MaximumBatchOperationCount); } [Theory] @@ -23,7 +25,7 @@ public void DefaultLimitsPreserveTheDocumentedSafeBudgetsAndBatchHardCap() [InlineData(1, 1, 0, 1, 1)] [InlineData(1, 1, 1, 0, 1)] [InlineData(1, 1, 1, 1, 0)] - [InlineData(1, 1, 1, 1, MemoryBatchLimits.MaximumOperations + 1)] + [InlineData(1, 1, 1, 1, MemoryBatchLimits.MaximumOperationCount + 1)] public void ExplicitLimitsRejectEveryInvalidBudget(int readBytes, int writeBytes, int stringBytes, int batchPayloadBytes, int batchOperationCount) { @@ -31,43 +33,41 @@ public void ExplicitLimitsRejectEveryInvalidBudget(int readBytes, int writeBytes batchPayloadBytes, batchOperationCount)); } - [Fact] - public void SnapshotIsIndependentAndValidatesConfigurationBoundProperties() - { - MemoryResourceLimits configured = new(33, 34, 35, 36, 37); - MemoryResourceLimits snapshot = configured.CreateSnapshot(); - configured.MaximumReadBytes = 1; - configured.MaximumWriteBytes = 2; - configured.MaximumStringBytes = 3; - configured.MaximumBatchPayloadBytes = 4; - configured.MaximumBatchOperationCount = 5; - - Assert.Equal(33, snapshot.MaximumReadBytes); - Assert.Equal(34, snapshot.MaximumWriteBytes); - Assert.Equal(35, snapshot.MaximumStringBytes); - Assert.Equal(36, snapshot.MaximumBatchPayloadBytes); - Assert.Equal(37, snapshot.MaximumBatchOperationCount); - Assert.Throws(() => new MemoryResourceLimits - { - MaximumBatchOperationCount = MemoryBatchLimits.MaximumOperations + 1 - }.CreateSnapshot()); - } - [Fact] public void ReadOutcomeCopiesItsCompletedPrefixAndExposesItsFailureDetails() { int[] prefix = [11, 22]; CheatEngineFailure cause = new(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitiveBatch", "Denied."); - MemoryPrimitiveBatchReadOutcome outcome = new(3, 2, 2, cause, prefix); + MemoryPrimitiveBatchReadOutcome outcome = new(3, prefix, 2, cause); prefix[0] = 99; - Assert.Equal(3, outcome.AttemptedCount); + Assert.Equal(3, outcome.RequestedCount); Assert.Equal(2, outcome.CompletedCount); Assert.Equal(2, outcome.FailedIndex); - Assert.Equal(cause, outcome.Cause); - Assert.Equal([11, 22], outcome.ReadPrefix); - Assert.False(outcome.Succeeded); + Assert.Equal(cause, outcome.Failure); + Assert.Equal([11, 22], outcome.Values); + Assert.False(outcome.IsSuccess); + } + + [Fact] + public void ReadOutcomeNamesTheArgumentThatContradictsTheRequest() + { + CheatEngineFailure cause = new(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitiveBatch", "Denied."); + + ArgumentOutOfRangeException noRequest = Assert.Throws(() => + new MemoryPrimitiveBatchReadOutcome(0, [], null, cause)); + ArgumentOutOfRangeException tooManyValues = Assert.Throws(() => + new MemoryPrimitiveBatchReadOutcome(1, [11, 22], null, null)); + ArgumentOutOfRangeException misplacedIndex = Assert.Throws(() => + new MemoryPrimitiveBatchReadOutcome(3, [11], 2, cause)); + ArgumentException missingFailure = Assert.Throws(() => + new MemoryPrimitiveBatchReadOutcome(2, [11], null, null)); + + Assert.Equal("requestedCount", noRequest.ParamName); + Assert.Equal("values", tooManyValues.ParamName); + Assert.Equal("failedIndex", misplacedIndex.ParamName); + Assert.Equal("failure", missingFailure.ParamName); } [Fact] @@ -82,6 +82,64 @@ public void WriteOutcomeDistinguishesPartialAndUnknownEffects() Assert.Equal(2, partial.CompletedCount); Assert.Equal(MemoryBatchWriteEffectState.Unknown, unknown.EffectState); Assert.Null(unknown.FailedIndex); - Assert.False(partial.Succeeded); + Assert.False(partial.IsSuccess); + } + + [Fact] + public void AnUnassignedWriteEffectStateIsUnknownAndNeverAnEstablishedEffect() + { + Assert.Equal(MemoryBatchWriteEffectState.Unknown, default(MemoryBatchWriteEffectState)); + Assert.Equal(0, (int) MemoryBatchWriteEffectState.Unknown); + Assert.Throws(() => new MemoryPrimitiveBatchWriteOutcome(1, 1, null, null, default)); + } + + /// A5: every primitive member of constrains its type to unmanaged. + [Fact] + public void EveryPrimitiveMemberConstrainsItsTypeToUnmanaged() + { + MethodInfo[] primitives = + [ + .. typeof(IMemoryClient).GetMethods() + .Where(static method => method.Name.Contains("Primitive", StringComparison.Ordinal)) + ]; + + Assert.Equal(10, primitives.Length); + Assert.All(primitives, static method => + { + Type parameter = Assert.Single(method.GetGenericArguments()); + Assert.True(parameter.GenericParameterAttributes.HasFlag( + GenericParameterAttributes.NotNullableValueTypeConstraint), method.Name); + Assert.Contains(parameter.GetCustomAttributesData(), static attribute => + attribute.AttributeType.FullName == "System.Runtime.CompilerServices.IsUnmanagedAttribute"); + }); + } + + [Fact] + public void ByteReadOutcomeSeparatesAConfirmedPrefixFromACompleteRead() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadBytes", "Partial."); + + MemoryBytesReadOutcome partial = new(4, [0x01, 0x02], failure); + MemoryBytesReadOutcome complete = new(2, [0x0A, 0x0B], null); + MemoryBytesReadOutcome refused = new(4, default, failure); + + Assert.Equal((4, 2, false), (partial.RequestedLength, partial.ConfirmedLength, partial.IsSuccess)); + Assert.Equal([0x01, 0x02], partial.Bytes); + Assert.Equal(failure, partial.Failure); + Assert.Equal((2, 2, true), (complete.RequestedLength, complete.ConfirmedLength, complete.IsSuccess)); + Assert.Null(complete.Failure); + Assert.True(refused.Bytes.IsEmpty); + Assert.Equal(0, refused.ConfirmedLength); + } + + [Fact] + public void ByteReadOutcomeRejectsAnInconsistentPrefixOrFailure() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadBytes", "Partial."); + + Assert.Throws(() => new MemoryBytesReadOutcome(0, [], failure)); + Assert.Throws(() => new MemoryBytesReadOutcome(1, [0x01, 0x02], failure)); + Assert.Throws(() => new MemoryBytesReadOutcome(2, [0x01], null)); + Assert.Throws(() => new MemoryBytesReadOutcome(1, [0x01], failure)); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs index 7f7f477..e9a7d3e 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs @@ -6,71 +6,163 @@ namespace CheatEngine.Client.Abstractions.Tests.Processes; public sealed class ProcessSnapshotTests { + private static readonly DateTimeOffset StartTime = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + [Fact] public void ProcessEnumerationRequestRejectsZeroMaximumAndAnEmptyNameFilter() { - Assert.Throws(() => new ProcessEnumerationRequest(0)); - Assert.Throws(() => new ProcessEnumerationRequest(1, string.Empty)); + Assert.Throws(() => new LocalProcessEnumerationRequest(0)); + Assert.Throws(() => new LocalProcessEnumerationRequest(1, string.Empty)); } [Fact] public void ProcessEnumerationResultNormalizesTheDefaultArrayAndRejectsEmptyTruncation() { - ProcessEnumerationResult result = new(default, false); + LocalProcessEnumerationResult result = new(default, false); Assert.Empty(result.Processes); Assert.False(result.IsTruncated); - Assert.Throws(() => new ProcessEnumerationResult([], true)); + Assert.Throws(() => new LocalProcessEnumerationResult([], true)); } [Fact] - public void ProcessStartRequestRequiresAbsoluteExecutableAndWorkingDirectoryPaths() - { - Assert.Throws(() => new ProcessStartRequest("target.exe")); - Assert.Throws(() => new ProcessStartRequest("C:\\target.exe", null, "working")); - - ProcessStartRequest request = new("C:\\target.exe", "--fixture", "C:\\working"); - Assert.Equal("C:\\target.exe", request.ExecutablePath); - Assert.Equal("--fixture", request.Arguments); - Assert.Equal("C:\\working", request.WorkingDirectory); - } - - [Fact] - public void ProcessPauseSnapshotPreservesCopiedTargetStateAndEpoch() - { - ProcessPauseSnapshot snapshot = new(new TargetProcessId(42), ProcessPauseState.Paused, 7); - - Assert.Equal(new TargetProcessId(42), snapshot.ProcessId); - Assert.Equal(ProcessPauseState.Paused, snapshot.State); - Assert.Equal(7, snapshot.SelectionEpoch); - } - - [Fact] - public void SnapshotPreservesCopiedIdentityArchitectureAndSelectionEpoch() + public void SnapshotPreservesEveryCopiedObservation() { ProcessSnapshot snapshot = new( new TargetProcessId(42), "fixture", "C:\\fixtures\\fixture.exe", + TargetBackend.LocalProcess, CheatEngineArchitecture.X64, + PointerSize.Bit64, + 8, + StartTime, 7); Assert.Equal(new TargetProcessId(42), snapshot.Id); Assert.Equal("fixture", snapshot.Name); Assert.Equal("C:\\fixtures\\fixture.exe", snapshot.ExecutablePath); - Assert.Equal(CheatEngineArchitecture.X64, snapshot.TargetArchitecture); - Assert.Equal(PointerSize.Bit64, snapshot.TargetPointerSize); + Assert.Equal(TargetBackend.LocalProcess, snapshot.Backend); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Architecture); + Assert.Equal(PointerSize.Bit64, snapshot.Bitness); + Assert.Equal(8, snapshot.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit64, snapshot.ConfiguredPointerSize); + Assert.False(snapshot.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(StartTime, snapshot.StartTimeUtc); Assert.Equal(7, snapshot.SelectionEpoch); } [Fact] - public void LegacyShapeLeavesTargetFactsUnknownAtTheInitialSelectionEpoch() + public void TheDefaultSnapshotKeepsEveryFactUnknown() + { + ProcessSnapshot snapshot = default; + + Assert.Equal(TargetBackend.Unknown, snapshot.Backend); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Architecture); + Assert.Equal(PointerSize.Unknown, snapshot.Bitness); + Assert.Null(snapshot.ConfiguredPointerSizeBytes); + Assert.Null(snapshot.ConfiguredPointerSizeDiffersFromBitness); + Assert.Null(snapshot.StartTimeUtc); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void SnapshotStoresTheObservedBitnessInsteadOfDerivingIt() + { + ProcessSnapshot unknownIsa = Create(CheatEngineArchitecture.Unknown, PointerSize.Bit64); + ProcessSnapshot unknownBitness = Create(CheatEngineArchitecture.X86, PointerSize.Unknown); + + Assert.Equal(CheatEngineArchitecture.Unknown, unknownIsa.Architecture); + Assert.Equal(PointerSize.Bit64, unknownIsa.Bitness); + Assert.Equal(CheatEngineArchitecture.X86, unknownBitness.Architecture); + Assert.Equal(PointerSize.Unknown, unknownBitness.Bitness); + Assert.NotEqual(unknownIsa, Create(CheatEngineArchitecture.Unknown, PointerSize.Bit32)); + } + + [Theory] + [Trait("Qualification", "Q31")] + [InlineData(4, true)] + [InlineData(8, false)] + [InlineData(2, true)] + [InlineData(null, null)] + public void SnapshotKeepsTheRawConfiguredPointerSizeApartFromTheBitness(int? configured, bool? differs) + { + // Spike C3 D3: setPointerSize(4) on an x64 target leaves targetIs64Bit true; any integer is accepted. + ProcessSnapshot snapshot = new(new TargetProcessId(42), null, null, TargetBackend.LocalProcess, + CheatEngineArchitecture.X64, PointerSize.Bit64, configured, null, 0); + + Assert.Equal(configured, snapshot.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit64, snapshot.Bitness); + Assert.Equal(differs, snapshot.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(configured switch + { + 4 => PointerSize.Bit32, + 8 => PointerSize.Bit64, + _ => PointerSize.Unknown + }, snapshot.ConfiguredPointerSize); + } + + [Theory] + [InlineData(CheatEngineArchitecture.X64, 4)] + [InlineData(CheatEngineArchitecture.X86, 8)] + [InlineData(CheatEngineArchitecture.Arm64, 4)] + [InlineData(CheatEngineArchitecture.Arm32, 8)] + public void SnapshotRejectsAKnownIsaWithAContradictoryBitness(CheatEngineArchitecture architecture, + int pointerBytes) + { + ArgumentException exception = Assert.Throws(() => + Create(architecture, new PointerSize(pointerBytes))); + + Assert.Equal("bitness", exception.ParamName); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(TargetBackend.CEServer, "fixture", null, false)] + [InlineData(TargetBackend.Unknown, null, "C:\\fixtures\\fixture.exe", false)] + [InlineData(TargetBackend.FileAsProcess, null, null, true)] + public void SnapshotRejectsLocalMetadataForABackendOtherThanALocalProcess(TargetBackend backend, string? name, + string? path, bool withStartTime) + { + // A local name, path or creation time does not describe a CEServer target, an unknown backend or a file. + ArgumentException exception = Assert.Throws(() => new ProcessSnapshot( + new TargetProcessId(42), name, path, backend, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, + withStartTime ? StartTime : null, 0)); + + Assert.Equal("backend", exception.ParamName); + } + + [Fact] + public void SnapshotAcceptsANonLocalBackendWithoutLocalMetadata() + { + ProcessSnapshot snapshot = new(new TargetProcessId(900), null, null, TargetBackend.CEServer, + CheatEngineArchitecture.Arm64, PointerSize.Bit64, 8, null, 1); + + Assert.Equal(TargetBackend.CEServer, snapshot.Backend); + Assert.Null(snapshot.StartTimeUtc); + } + + [Fact] + public void SnapshotRejectsAStartTimeThatIsNotUtcAndAnUndefinedBackend() { - ProcessSnapshot snapshot = new(new TargetProcessId(42), "fixture", null); + ArgumentException local = Assert.Throws(() => new ProcessSnapshot(new TargetProcessId(42), + null, null, TargetBackend.LocalProcess, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, + new DateTimeOffset(2026, 9, 24, 12, 0, 0, TimeSpan.FromHours(2)), 0)); + ArgumentOutOfRangeException undefined = Assert.Throws(() => new ProcessSnapshot( + new TargetProcessId(42), null, null, (TargetBackend) 99, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, + null, 0)); - Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); - Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); - Assert.Equal(0, snapshot.SelectionEpoch); + Assert.Equal("startTimeUtc", local.ParamName); + Assert.Equal("backend", undefined.ParamName); + } + + [Theory] + [InlineData("", null)] + [InlineData(null, "")] + public void SnapshotRejectsAnEmptyNameOrPath(string? name, string? path) + { + Assert.Throws(() => new ProcessSnapshot(new TargetProcessId(42), name, path, + TargetBackend.LocalProcess, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, null, 0)); } [Theory] @@ -78,11 +170,13 @@ public void LegacyShapeLeavesTargetFactsUnknownAtTheInitialSelectionEpoch() [InlineData(long.MinValue)] public void SnapshotRejectsNegativeSelectionEpoch(long selectionEpoch) { - Assert.Throws(() => new ProcessSnapshot( - new TargetProcessId(42), - "fixture", - null, - CheatEngineArchitecture.X64, - selectionEpoch)); + Assert.Throws(() => new ProcessSnapshot(new TargetProcessId(42), "fixture", null, + TargetBackend.LocalProcess, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, null, selectionEpoch)); + } + + private static ProcessSnapshot Create(CheatEngineArchitecture architecture, PointerSize bitness) + { + return new ProcessSnapshot(new TargetProcessId(42), "fixture", null, TargetBackend.LocalProcess, architecture, + bitness, null, null, 3); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/README.md b/tests/CheatEngine.Client.Abstractions.Tests/README.md index b41c2e3..54f1957 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/README.md +++ b/tests/CheatEngine.Client.Abstractions.Tests/README.md @@ -9,7 +9,8 @@ functional CheatEngine.Client namespaces. Contracts are consumed by every layer and should remain meaningful without a running Cheat Engine host. This suite tests AOB pattern normalization, bounded memory requests and pointer chains, process and runtime snapshots, failure -classification, inspection leases, table definitions, Lua contracts, and the public value-scan state model. +classification, inspection leases, table definitions, Lua contracts, the experimental value-scan requests, +values, pages and states, and the experimental allocation requests. ## How it helps improve CheatEngine.Client @@ -17,8 +18,8 @@ The tests make validation rules and default values executable. They prevent an A request, erasing an expected failure category, or exposing a host-specific behavior in a contract that consumers must be able to use during design time and unit testing. -They do not run Cheat Engine and do not claim live value-scan support; the latter is tested here only as a -capability-gated public contract. +They do not run Cheat Engine and do not claim live value-scan or allocation support; the value scans and the +allocations are tested here only as experimental public contracts (`CECLIENT5001`, `CECLIENT5002`). ## Run diff --git a/tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs deleted file mode 100644 index f95af65..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using CheatEngine.Client.RemoteExecution; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Abstractions.Tests.RemoteExecution; - -public sealed class RemoteExecutionContractsTests -{ - [Fact] - public void DllInjectionRequestPreservesAnAbsoluteDllPath() - { - string path = Path.Combine(Path.GetTempPath(), "client-plugin.dll"); - - RemoteDllInjectionRequest request = new(path); - - Assert.Equal(path, request.LibraryPath); - } - - [Theory] - [InlineData("")] - [InlineData("relative.dll")] - [InlineData("C:\\plugins\\client.txt")] - public void DllInjectionRequestRejectsRelativeOrNonDllPaths(string path) - { - Assert.Throws(() => new RemoteDllInjectionRequest(path)); - } - - [Fact] - public void RemoteCallRequestCopiesParameterBytesAndPreservesAPositiveTimeout() - { - byte[] parameters = [1, 2, 3]; - RemoteCallRequest request = new(0x401000, parameters, TimeSpan.FromMilliseconds(25)); - parameters[0] = 99; - - Assert.Equal((Address) 0x401000, request.EntryPoint); - Assert.Equal([1, 2, 3], request.Parameters); - Assert.Equal(TimeSpan.FromMilliseconds(25), request.Timeout); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void RemoteCallRequestRejectsANonPositiveTimeout(int milliseconds) - { - Assert.Throws(() => new RemoteCallRequest( - 0x401000, [], TimeSpan.FromMilliseconds(milliseconds))); - } - - [Fact] - public void RemoteCallResultCopiesOutputBytes() - { - byte[] output = [0x10, 0x20]; - RemoteCallResult result = new(42, output); - output[0] = 0xCC; - - Assert.Equal(42UL, result.ReturnValue); - Assert.Equal([0x10, 0x20], result.Output); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs index d8b44a3..01d04a8 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; + using CheatEngine.Client.Results; namespace CheatEngine.Client.Abstractions.Tests.Results; @@ -43,7 +45,8 @@ public void ThrowCreatesOperationExceptionThatRetainsFailure() "Lua returned an error.", innerException); - CheatEngineOperationException exception = Assert.Throws(failure.Throw); + CheatEngineOperationException exception = + Assert.Throws(() => failure.Throw(CancellationToken.None)); Assert.Equal(failure, exception.Failure); Assert.Equal(failure.Message, exception.Message); @@ -62,7 +65,7 @@ public void ThrowPreservesTheDedicatedActivationExpiredException() innerException); CheatEngineActivationExpiredException exception = - Assert.Throws(failure.Throw); + Assert.Throws(() => failure.Throw(CancellationToken.None)); Assert.Equal(failure, exception.Failure); Assert.Same(innerException, exception.InnerException); @@ -79,24 +82,320 @@ public void ThrowPreservesTheDedicatedLifecycleExceptionForInvalidState() "The provider is stopping.", innerException); - CheatEngineClientLifecycleException exception = - Assert.Throws(failure.Throw); + CheatEngineInvalidStateException exception = + Assert.Throws(() => failure.Throw(CancellationToken.None)); + + Assert.Equal(failure, exception.Failure); + Assert.Equal(failure.Message, exception.Message); + Assert.Same(innerException, exception.InnerException); + } + + /// Omitting the host effect keeps it conservative, and an explicit host effect round-trips. + [Fact] + public void HostEffectDefaultsToUnknownAndRoundTripsThroughTheConstructor() + { + InvalidOperationException innerException = new("release failed"); + + CheatEngineFailure omitted = new(CheatEngineFailureKind.LuaError, "Lua.Execute", "Lua failed."); + CheatEngineFailure explicitEffect = new(CheatEngineFailureKind.InvalidState, "Patterns.Scan", + "The release was not confirmed.", innerException, CheatEngineHostEffect.CleanupUnconfirmed); + + Assert.Equal(CheatEngineHostEffect.Unknown, default(CheatEngineFailure).HostEffect); + Assert.Equal(CheatEngineHostEffect.Unknown, omitted.HostEffect); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, explicitEffect.HostEffect); + Assert.Equal(CheatEngineFailureKind.InvalidState, explicitEffect.Kind); + Assert.Same(innerException, explicitEffect.Exception); + Assert.NotEqual(explicitEffect, new CheatEngineFailure(explicitEffect.Kind, explicitEffect.Operation, + explicitEffect.Message, innerException, CheatEngineHostEffect.Completed)); + } + + /// The failure has exactly one public constructor, whose trailing parameters are optional. + [Fact] + public void FailureHasExactlyOneConstructor() + { + ConstructorInfo constructor = Assert.Single(typeof(CheatEngineFailure).GetConstructors()); + + Assert.Equal(["kind", "operation", "message", "exception", "hostEffect"], + constructor.GetParameters().Select(static parameter => parameter.Name)); + Assert.Equal([false, false, false, true, true], + constructor.GetParameters().Select(static parameter => parameter.IsOptional)); + } + + /// A default failure is safe to read: its strings are empty, never null, and it reports itself as default. + [Fact] + public void DefaultFailureIsSafeToReadAndReportsItself() + { + CheatEngineFailure failure = default; + + Assert.True(failure.IsDefault); + Assert.Equal(string.Empty, failure.Operation); + Assert.Equal(string.Empty, failure.Message); + Assert.Equal(CheatEngineFailureKind.Unknown, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Null(failure.Exception); + Assert.Equal("Unknown in (host effect: Unknown)", failure.ToString()); + } + + /// Every constructed failure, even one of kind Unknown, is distinct from the default value. + [Fact] + public void ConstructedFailuresAreNeverDefault() + { + CheatEngineFailure unknown = new(CheatEngineFailureKind.Unknown, "Runtime.GetSnapshot", "Unclassified."); + + Assert.False(unknown.IsDefault); + Assert.NotEqual(default, unknown); + Assert.Equal("Runtime.GetSnapshot", unknown.Operation); + Assert.Equal("Unclassified.", unknown.Message); + } + + /// A null operation or message is rejected like an empty one. + [Fact] + public void ConstructorRejectsNullOperationOrMessage() + { + Assert.Throws(() => new CheatEngineFailure(CheatEngineFailureKind.Unknown, null!, "message")); + Assert.Throws(() => new CheatEngineFailure(CheatEngineFailureKind.Unknown, "Operation", null!)); + } + + /// Rejects a host effect outside the documented vocabulary instead of storing an unclassifiable value. + [Theory] + [InlineData(-1)] + [InlineData(6)] + [InlineData(int.MaxValue)] + public void ConstructorRejectsAnUndefinedHostEffect(int value) + { + ArgumentOutOfRangeException exception = Assert.Throws(() => + new CheatEngineFailure(CheatEngineFailureKind.Unknown, "Operation", "message", null, + (CheatEngineHostEffect) value)); + + Assert.Equal("hostEffect", exception.ParamName); + } + + /// The indeterminate AOB "no result list" outcome is an operation failure, never a lifecycle fault. + [Fact] + [Trait("Qualification", "Q27")] + public void IndeterminateHostResultThrowsTheOperationException() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.IndeterminateHostResult, "Patterns.Scan", + "Cheat Engine returned no AOB result list.", null, CheatEngineHostEffect.Completed); + + CheatEngineOperationException exception = + Assert.Throws(() => failure.Throw(CancellationToken.None)); + + Assert.Equal(failure, exception.Failure); + Assert.Equal(16, (int) CheatEngineFailureKind.IndeterminateHostResult); + } + + /// The target, identity and runtime change kinds keep their published values after the 0 to 16 range. + [Fact] + public void TargetIdentityAndRuntimeChangeKindsHaveStableValues() + { + Assert.Equal(17, (int) CheatEngineFailureKind.TargetChanged); + Assert.Equal(18, (int) CheatEngineFailureKind.TargetIdentityUnavailable); + Assert.Equal(19, (int) CheatEngineFailureKind.RuntimeChanged); + Assert.Equal(Enumerable.Range(0, 20), Enum.GetValues().Select(static kind => (int) kind)); + } + + /// A target, identity or runtime change is an operation failure, never a lifecycle exception. + [Theory] + [InlineData(CheatEngineFailureKind.TargetChanged)] + [InlineData(CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData(CheatEngineFailureKind.RuntimeChanged)] + public void ChangeKindsThrowTheOperationException(CheatEngineFailureKind kind) + { + CheatEngineFailure failure = new(kind, "Patterns.Scan", "The target changed during the scan.", null, + CheatEngineHostEffect.Completed); + + CheatEngineOperationException exception = + Assert.Throws(() => failure.Throw(CancellationToken.None)); + + Assert.Equal(failure, exception.Failure); + Assert.Equal(kind, exception.Failure.Kind); + } + + /// The documented negative result is a host effect of its own, with a published value. + [Fact] + public void NotAppliedIsADefinedHostEffectThatRoundTrips() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.OperationRejected, "Tables.SetActive", + "Cheat Engine refused the activation.", null, CheatEngineHostEffect.NotApplied); + + Assert.Equal(5, (int) CheatEngineHostEffect.NotApplied); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.Equal("OperationRejected in Tables.SetActive (host effect: NotApplied)", failure.ToString()); + Assert.Equal(Enumerable.Range(0, 6), Enum.GetValues().Select(static effect => (int) effect)); + } + + /// The dedicated lifecycle exceptions keep the complete failure, including its host effect. + [Theory] + [InlineData(CheatEngineFailureKind.ActivationExpired)] + [InlineData(CheatEngineFailureKind.InvalidState)] + public void ThrowPreservesTheHostEffectInLifecycleExceptions(CheatEngineFailureKind kind) + { + CheatEngineFailure failure = new(kind, "Patterns.Scan", "The release was not confirmed.", null, + CheatEngineHostEffect.CleanupUnconfirmed); + + CheatEngineClientException exception = + Assert.ThrowsAny(() => failure.Throw(CancellationToken.None)); + + Assert.Equal(failure, exception.Failure); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, exception.Failure.HostEffect); + } + + /// + /// A cancelled failure throws an that keeps the failure and the token the + /// operation observed, whatever its host effect. + /// + [Theory] + [InlineData(CheatEngineHostEffect.NotStarted)] + [InlineData(CheatEngineHostEffect.Completed)] + public void ThrowRaisesAnOperationCanceledExceptionForACancelledFailure(CheatEngineHostEffect hostEffect) + { + using CancellationTokenSource source = new(); + source.Cancel(); + InvalidOperationException innerException = new("observed after the copy"); + CheatEngineFailure failure = new(CheatEngineFailureKind.Cancelled, "Memory.Read", + "The operation was cancelled.", innerException, hostEffect); + + CheatEngineOperationCanceledException exception = + Assert.Throws(() => failure.Throw(source.Token)); + Assert.IsType(exception, exactMatch: false); Assert.Equal(failure, exception.Failure); + Assert.Equal(hostEffect, exception.Failure.HostEffect); + Assert.Equal(source.Token, exception.CancellationToken); Assert.Equal(failure.Message, exception.Message); Assert.Same(innerException, exception.InnerException); } - /// Assigns each concrete lifecycle exception the stable failure kind it represents. + /// With the empty token, the cancelled failure still throws the cancellation exception. + [Fact] + public void ThrowWithTheNoneTokenStillRaisesTheCancellationException() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.Cancelled, "Patterns.Scan", "The scan was cancelled.", + null, CheatEngineHostEffect.NotStarted); + + CheatEngineOperationCanceledException exception = + Assert.Throws(() => failure.Throw(CancellationToken.None)); + + Assert.Equal(CancellationToken.None, exception.CancellationToken); + Assert.Equal(failure, exception.Failure); + } + + /// Only a cancelled failure throws the cancellation exception; the token never changes the mapping. + [Theory] + [InlineData(CheatEngineFailureKind.Unknown, typeof(CheatEngineOperationException))] + [InlineData(CheatEngineFailureKind.Cancelled, typeof(CheatEngineOperationCanceledException))] + [InlineData(CheatEngineFailureKind.OperationRejected, typeof(CheatEngineOperationException))] + [InlineData(CheatEngineFailureKind.ActivationExpired, typeof(CheatEngineActivationExpiredException))] + [InlineData(CheatEngineFailureKind.InvalidState, typeof(CheatEngineInvalidStateException))] + [InlineData(CheatEngineFailureKind.RuntimeChanged, typeof(CheatEngineOperationException))] + public void ThrowMapsEachKindToOneExceptionType(CheatEngineFailureKind kind, Type expected) + { + using CancellationTokenSource source = new(); + source.Cancel(); + CheatEngineFailure failure = new(kind, "Tables.Find", "The operation failed.", null, + CheatEngineHostEffect.NotStarted); + + Exception exception = Assert.ThrowsAny(() => failure.Throw(source.Token)); + + Assert.IsType(expected, exception); + } + + /// A default failure describes no failure: throwing it is a programming error, not an operation failure. + [Fact] + public void ThrowRejectsTheDefaultFailure() + { + CheatEngineFailure failure = default; + + InvalidOperationException exception = + Assert.Throws(() => failure.Throw(CancellationToken.None)); + + Assert.Null(exception.InnerException); + } + + /// Formatting the failure object, as structured loggers do, never emits user data. + [Fact] + [Trait("Qualification", "Q46")] + public void ToStringOmitsMessageAndException() + { + InvalidOperationException innerException = new("lua: attempt to call nil 'secretGlobal' at C:\\Users\\player\\t.ct"); + CheatEngineFailure failure = new(CheatEngineFailureKind.LuaError, "UnsafeLua.Execute", + "The protected Lua call failed at 0x7FFC7A0A0000 while reading game.exe+1234.", innerException, + CheatEngineHostEffect.Unknown); + + string text = failure.ToString(); + + Assert.Equal("LuaError in UnsafeLua.Execute (host effect: Unknown)", text); + Assert.DoesNotContain("0x7FFC", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("game.exe", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("secretGlobal", text, StringComparison.Ordinal); + Assert.DoesNotContain(nameof(InvalidOperationException), text, StringComparison.Ordinal); + Assert.Equal("Unknown in (host effect: Unknown)", default(CheatEngineFailure).ToString()); + } + + /// + /// creates, without throwing, the exception that + /// throws: its type follows the kind and it keeps the complete failure. + /// + [Theory] + [InlineData(CheatEngineFailureKind.Cancelled, typeof(CheatEngineOperationCanceledException))] + [InlineData(CheatEngineFailureKind.ActivationExpired, typeof(CheatEngineActivationExpiredException))] + [InlineData(CheatEngineFailureKind.InvalidState, typeof(CheatEngineInvalidStateException))] + [InlineData(CheatEngineFailureKind.NotFound, typeof(CheatEngineOperationException))] + [InlineData(CheatEngineFailureKind.Unknown, typeof(CheatEngineOperationException))] + public void ToExceptionCreatesTheExceptionThatThrowThrows(CheatEngineFailureKind kind, Type expected) + { + CancellationToken token = TestContext.Current.CancellationToken; + CheatEngineFailure failure = new(kind, "Table.Read", "The operation failed.", null, + CheatEngineHostEffect.NotStarted); + + Exception created = failure.ToException(token); + Exception thrown = Assert.ThrowsAny(() => failure.Throw(token)); + + Assert.IsType(expected, created); + Assert.IsType(expected, thrown); + CheatEngineFailure carried = created is CheatEngineOperationCanceledException cancelled + ? cancelled.Failure + : ((CheatEngineClientException) created).Failure; + Assert.Equal(failure, carried); + Assert.Equal(CheatEngineHostEffect.NotStarted, carried.HostEffect); + if (created is OperationCanceledException canceled) + { + Assert.Equal(token, canceled.CancellationToken); + } + } + + [Fact] + public void ADefaultFailureHasNoException() + { + Assert.Throws(() => + default(CheatEngineFailure).ToException(TestContext.Current.CancellationToken)); + } + + [Fact] + public void AnUndefinedKindIsRejected() + { + ArgumentOutOfRangeException exception = Assert.Throws(() => + new CheatEngineFailure((CheatEngineFailureKind) 999, "Table.Read", "The operation failed.")); + + Assert.Equal("kind", exception.ParamName); + } + + /// No Client exception has a public or protected constructor: a failure is the only way to one. [Fact] - public void LifecycleExceptionsClassifyTheirSpecificLifecycleFailures() + public void NoClientExceptionHasAPublicOrProtectedConstructor() { - CheatEngineActivationExpiredException expired = new("Table.Read", "The epoch changed."); - CheatEngineClientLifecycleException stopping = new("Client.Track", "The provider is stopping."); + Type[] exceptions = + [ + typeof(CheatEngineClientException), typeof(CheatEngineActivationExpiredException), + typeof(CheatEngineInvalidStateException), typeof(CheatEngineOperationException), + typeof(CheatEngineOperationCanceledException) + ]; - Assert.Equal(CheatEngineFailureKind.ActivationExpired, expired.Failure.Kind); - Assert.Equal("Table.Read", expired.Failure.Operation); - Assert.Equal(CheatEngineFailureKind.InvalidState, stopping.Failure.Kind); - Assert.Equal("Client.Track", stopping.Failure.Operation); + Assert.All(exceptions, static type => Assert.DoesNotContain( + type.GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance), + static constructor => constructor.IsPublic || constructor.IsFamily || constructor.IsFamilyOrAssembly)); + Assert.True(typeof(CheatEngineClientException).IsAbstract); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Results/LeaseReleaseOutcomeTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Results/LeaseReleaseOutcomeTests.cs new file mode 100644 index 0000000..fe47437 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Results/LeaseReleaseOutcomeTests.cs @@ -0,0 +1,118 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Abstractions.Tests.Results; + +public sealed class LeaseReleaseOutcomeTests +{ + /// Every kind keeps its published value; the vocabulary is the contiguous range 0 to 12. + [Fact] + public void KindsHaveStableExplicitValues() + { + Assert.Equal(0, (int) LeaseReleaseKind.Unknown); + Assert.Equal(1, (int) LeaseReleaseKind.Released); + Assert.Equal(2, (int) LeaseReleaseKind.AlreadyReleased); + Assert.Equal(3, (int) LeaseReleaseKind.PartiallyReleased); + Assert.Equal(4, (int) LeaseReleaseKind.Replaced); + Assert.Equal(5, (int) LeaseReleaseKind.Superseded); + Assert.Equal(6, (int) LeaseReleaseKind.ExternallyRemoved); + Assert.Equal(7, (int) LeaseReleaseKind.RefusedTargetNotAttached); + Assert.Equal(8, (int) LeaseReleaseKind.RefusedTargetChanged); + Assert.Equal(9, (int) LeaseReleaseKind.RefusedTargetIdentityUnavailable); + Assert.Equal(10, (int) LeaseReleaseKind.RefusedRuntimeChanged); + Assert.Equal(11, (int) LeaseReleaseKind.CleanupUnconfirmed); + Assert.Equal(12, (int) LeaseReleaseKind.CleanupUnavailable); + Assert.Equal(Enumerable.Range(0, 13), Enum.GetValues().Select(static kind => (int) kind)); + } + + /// + /// Each kind sets exactly one of the three flags; only Unknown and CleanupUnavailable are retryable, as in + /// CheatEngine.SDK (A9). + /// + [Theory] + [InlineData(LeaseReleaseKind.Unknown, false, true, false)] + [InlineData(LeaseReleaseKind.Released, true, false, false)] + [InlineData(LeaseReleaseKind.AlreadyReleased, true, false, false)] + [InlineData(LeaseReleaseKind.PartiallyReleased, false, false, true)] + [InlineData(LeaseReleaseKind.Replaced, true, false, false)] + [InlineData(LeaseReleaseKind.Superseded, true, false, false)] + [InlineData(LeaseReleaseKind.ExternallyRemoved, true, false, false)] + [InlineData(LeaseReleaseKind.RefusedTargetNotAttached, false, false, true)] + [InlineData(LeaseReleaseKind.RefusedTargetChanged, false, false, true)] + [InlineData(LeaseReleaseKind.RefusedTargetIdentityUnavailable, false, false, true)] + [InlineData(LeaseReleaseKind.RefusedRuntimeChanged, false, false, true)] + [InlineData(LeaseReleaseKind.CleanupUnconfirmed, false, false, true)] + [InlineData(LeaseReleaseKind.CleanupUnavailable, false, true, false)] + public void EachKindSetsExactlyOneFlag(LeaseReleaseKind kind, bool complete, bool retryable, bool manualRecovery) + { + LeaseReleaseOutcome outcome = new(kind, CheatEngineHostEffect.NotStarted); + + Assert.Equal(complete, outcome.IsComplete); + Assert.Equal(retryable, outcome.IsRetryable); + Assert.Equal(manualRecovery, outcome.RequiresManualRecovery); + Assert.Equal(1, (outcome.IsComplete ? 1 : 0) + (outcome.IsRetryable ? 1 : 0) + + (outcome.RequiresManualRecovery ? 1 : 0)); + } + + /// The theory above covers every defined kind, so a new kind fails here until its flags are decided. + [Fact] + public void TheFlagTableCoversEveryKind() + { + int retryable = Enum.GetValues() + .Count(static kind => new LeaseReleaseOutcome(kind, CheatEngineHostEffect.Unknown).IsRetryable); + + Assert.Equal(13, Enum.GetValues().Length); + Assert.Equal(2, retryable); + } + + /// The default outcome never reads as a release: it is Unknown, with an unknown effect, and retryable. + [Fact] + public void DefaultOutcomeIsAnUnknownRetryableOutcome() + { + LeaseReleaseOutcome outcome = default; + + Assert.Equal(LeaseReleaseKind.Unknown, outcome.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, outcome.HostEffect); + Assert.False(outcome.IsComplete); + Assert.True(outcome.IsRetryable); + Assert.False(outcome.RequiresManualRecovery); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown), outcome); + } + + /// Kind and host effect round-trip and take part in equality. + [Fact] + public void KindAndHostEffectRoundTripAndDefineEquality() + { + LeaseReleaseOutcome unconfirmed = new(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started); + + Assert.Equal(LeaseReleaseKind.CleanupUnconfirmed, unconfirmed.Kind); + Assert.Equal(CheatEngineHostEffect.Started, unconfirmed.HostEffect); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + unconfirmed); + Assert.NotEqual(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + unconfirmed); + } + + /// An undefined kind or host effect is rejected instead of being stored unclassified. + [Theory] + [InlineData(-1, 0, "kind")] + [InlineData(13, 0, "kind")] + [InlineData(0, -1, "hostEffect")] + [InlineData(0, 6, "hostEffect")] + public void ConstructorRejectsUndefinedValues(int kind, int hostEffect, string parameter) + { + ArgumentOutOfRangeException exception = Assert.Throws(() => + new LeaseReleaseOutcome((LeaseReleaseKind) kind, (CheatEngineHostEffect) hostEffect)); + + Assert.Equal(parameter, exception.ParamName); + } + + /// Formatting the outcome, as structured loggers do, emits only the kind and the effect. + [Fact] + [Trait("Qualification", "Q46")] + public void ToStringReturnsOnlyTheKindAndTheEffect() + { + LeaseReleaseOutcome outcome = new(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted); + + Assert.Equal("RefusedTargetChanged (host effect: NotStarted)", outcome.ToString()); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs index 2f0dc58..72e8298 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs @@ -1,5 +1,3 @@ -using System.Globalization; - using CheatEngine.Client.Runtime; using CheatEngine.SDK.Engine.Runtime; @@ -7,91 +5,106 @@ namespace CheatEngine.Client.Abstractions.Tests.Runtime; public sealed class CheatEngineRuntimeSnapshotTests { - /// Keeps an observed coarse CE version distinct from its qualified four-part release baseline. + /// Keeps the version, platform and capability groups exactly as they were supplied. [Fact] - public void SnapshotKeepsObservedCeVersionSeparateFromTheQualifiedFourPartBaseline() + public void SnapshotKeepsItsThreeGroups() { - CheatEngineRuntimeSnapshot snapshot = Create(); + CheatEngineRuntimeVersionInfo version = CreateVersionInfo(new CheatEngineVersion(7, 7, 0, 9999)); + CheatEngineRuntimePlatformInfo platform = CreatePlatformInfo(TargetBackend.LocalProcess, + CheatEngineArchitecture.X86, PointerSize.Bit32, 4); + ClientCapabilities capabilities = ClientCapabilities.Empty; + + CheatEngineRuntimeSnapshot snapshot = new(42, version, platform, capabilities); - Assert.Equal(7.7d, snapshot.ObservedCheatEngineVersion); - Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.QualifiedCheatEngineBaseline); - Assert.NotEqual( - snapshot.ObservedCheatEngineVersion!.Value.ToString(CultureInfo.InvariantCulture), - snapshot.QualifiedCheatEngineBaseline.ToString()); - Assert.True(snapshot.IsOnQualifiedCheatEngineLine); + Assert.Equal(42, snapshot.Epoch); + Assert.Equal(version, snapshot.Version); + Assert.Equal(platform, snapshot.Platform); + Assert.Same(capabilities, snapshot.Capabilities); } - /// Forwards grouped runtime observations through the established leaf-property compatibility surface. + /// Keeps the observed four-part CE file version distinct from the qualified release baseline. [Fact] - public void SnapshotForwardsVersionAndPlatformComponentsToItsExistingLeafProperties() - { - Version clientAssemblyVersion = new(0, 1, 2, 3); - Version sdkAssemblyVersion = new(1, 2, 3, 4); - CheatEngineRuntimeVersionInfo version = new(7.7d, CheatEngineVersion.Ce77010621, clientAssemblyVersion, - sdkAssemblyVersion); - CheatEngineRuntimePlatformInfo platform = new( - CheatEngineArchitecture.X64, - CheatEngineArchitecture.X86, - PointerSize.Bit32, - TargetAbi.Windows); - CheatEngineRuntimeSnapshot snapshot = new( - 42, - version, - platform, - RuntimeCapabilities.Empty, - ClientCapabilities.Empty); + public void VersionInfoKeepsTheObservedFileVersionSeparateFromTheQualifiedBaseline() + { + CheatEngineRuntimeVersionInfo version = CreateVersionInfo(new CheatEngineVersion(7, 7, 0, 9999)); - Assert.Equal(version, snapshot.Version); - Assert.Equal(platform, snapshot.Platform); - Assert.Equal(version.ObservedCheatEngineVersion, snapshot.ObservedCheatEngineVersion); - Assert.Equal(version.QualifiedCheatEngineBaseline, snapshot.QualifiedCheatEngineBaseline); - Assert.Same(clientAssemblyVersion, snapshot.ClientAssemblyVersion); - Assert.Same(sdkAssemblyVersion, snapshot.SdkAssemblyVersion); - Assert.Equal(platform.SystemArchitecture, snapshot.SystemArchitecture); - Assert.Equal(platform.TargetArchitecture, snapshot.TargetArchitecture); - Assert.Equal(platform.TargetPointerSize, snapshot.TargetPointerSize); - Assert.Equal(platform.TargetAbi, snapshot.TargetAbi); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 9999), version.CheatEngineVersion); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), version.QualifiedCheatEngineBaseline); + Assert.True(version.IsOnQualifiedCheatEngineLine); } - /// Rejects non-finite or negative observed CE version values. + /// + /// Compares the major and minor components as integers: 7.10 is its own line, never 7.1 as a coarse decimal + /// number would read it. + /// [Theory] - [InlineData(double.NaN)] - [InlineData(double.PositiveInfinity)] - [InlineData(-0.1d)] - public void VersionInfoRejectsInvalidObservedCeVersion(double observedVersion) + [InlineData(7, 7, 7, 7, true)] + [InlineData(7, 7, 7, 10, false)] + [InlineData(7, 7, 7, 1, false)] + [InlineData(7, 7, 8, 7, false)] + [InlineData(7, 10, 7, 10, true)] + [InlineData(7, 10, 7, 1, false)] + [InlineData(7, 10, 7, 11, false)] + public void IsOnQualifiedCheatEngineLineComparesMajorAndMinorAsIntegers(int baselineMajor, int baselineMinor, + int major, int minor, bool expected) { - Assert.Throws(() => CreateVersionInfo(observedVersion)); + CheatEngineRuntimeVersionInfo version = new(new CheatEngineVersion(major, minor, 0, 1), + new CheatEngineVersion(baselineMajor, baselineMinor, 0, 10621), new Version(1, 0, 0), new Version(2, 0, 0), + null, false); + + Assert.Equal(expected, version.IsOnQualifiedCheatEngineLine); } - /// Rejects absent assembly versions that would leave a version observation uninitialized. + /// An unobserved version is never on the qualified line. [Fact] - public void VersionInfoRejectsNullAssemblyVersions() + public void VersionInfoAllowsAnUnobservedCheatEngineVersion() { - ArgumentNullException clientVersion = Assert.Throws(() => - new CheatEngineRuntimeVersionInfo( - 7.7d, - CheatEngineVersion.Ce77010621, - Null(), - new Version(1, 0, 0))); - ArgumentNullException sdkVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( - 7.7d, - CheatEngineVersion.Ce77010621, - new Version(0, 1, 0), - Null())); + CheatEngineRuntimeVersionInfo version = CreateVersionInfo(null); - Assert.Equal("clientAssemblyVersion", clientVersion.ParamName); - Assert.Equal("sdkAssemblyVersion", sdkVersion.ParamName); + Assert.Null(version.CheatEngineVersion); + Assert.False(version.IsOnQualifiedCheatEngineLine); + } + + /// Reports the loaded CheatEngine.SDK package and whether it is exactly the reviewed one. + [Theory] + [InlineData("2.0.0+325c47b573f8bd39a247f1d0101f110fa36c1696", true)] + [InlineData("2.0.1", false)] + [InlineData(null, false)] + public void VersionInfoReportsTheLoadedSdkPackage(string? packageVersion, bool reviewed) + { + CheatEngineRuntimeVersionInfo version = new(CheatEngineVersion.Ce77010621, CheatEngineVersion.Ce77010621, + new Version(1, 0, 0), new Version(2, 0, 0), packageVersion, reviewed); + + Assert.Equal(packageVersion, version.SdkPackageVersion); + Assert.Equal(reviewed, version.IsReviewedSdkPackage); } - /// Retains unavailable version and target facts without inferring values that CE did not provide. + /// A reviewed package needs its version, and a package version is never blank. [Fact] - public void SnapshotAllowsAnUnavailableObservedVersionAndKeepsUnknownTargetFacts() + public void VersionInfoRejectsABlankPackageVersionAndAReviewedPackageWithoutOne() { - CheatEngineRuntimeSnapshot snapshot = Create(null); + ArgumentException blank = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( + null, CheatEngineVersion.Ce77010621, new Version(1, 0, 0), new Version(2, 0, 0), " ", false)); + ArgumentException reviewed = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( + null, CheatEngineVersion.Ce77010621, new Version(1, 0, 0), new Version(2, 0, 0), null, true)); - Assert.Null(snapshot.ObservedCheatEngineVersion); - Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); - Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); + Assert.Equal("sdkPackageVersion", blank.ParamName); + Assert.Equal("isReviewedSdkPackage", reviewed.ParamName); + } + + /// Rejects absent assembly versions that would leave a version observation uninitialized. + [Fact] + public void VersionInfoRejectsNullAssemblyVersions() + { + ArgumentNullException clientVersion = Assert.Throws(() => + new CheatEngineRuntimeVersionInfo(null, CheatEngineVersion.Ce77010621, Null(), + new Version(1, 0, 0), null, false)); + ArgumentNullException sdkVersion = Assert.Throws(() => + new CheatEngineRuntimeVersionInfo(null, CheatEngineVersion.Ce77010621, new Version(0, 1, 0), + Null(), null, false)); + + Assert.Equal("clientAssemblyVersion", clientVersion.ParamName); + Assert.Equal("sdkAssemblyVersion", sdkVersion.ParamName); } /// Rejects a snapshot epoch that cannot identify a real activation. @@ -100,103 +113,148 @@ public void SnapshotAllowsAnUnavailableObservedVersionAndKeepsUnknownTargetFacts [InlineData(long.MinValue)] public void SnapshotRejectsNegativeActivationEpoch(long epoch) { - Assert.Throws(() => Create(epoch: epoch)); + Assert.Throws(() => new CheatEngineRuntimeSnapshot(epoch, + CreateVersionInfo(CheatEngineVersion.Ce77010621), CreatePlatformInfo(), ClientCapabilities.Empty)); + } + + /// Rejects an absent capability collection instead of accepting an incomplete runtime snapshot. + [Fact] + public void SnapshotRejectsANullCapabilityCollection() + { + ArgumentNullException exception = Assert.Throws(() => new CheatEngineRuntimeSnapshot(42, + CreateVersionInfo(CheatEngineVersion.Ce77010621), CreatePlatformInfo(), Null())); + + Assert.Equal("capabilities", exception.ParamName); + } + + /// Rejects the default version-info value before it can produce a partially initialized snapshot. + [Fact] + public void SnapshotRejectsDefaultVersionInfo() + { + ArgumentNullException exception = Assert.Throws(() => new CheatEngineRuntimeSnapshot( + 42, default, CreatePlatformInfo(), ClientCapabilities.Empty)); + + Assert.Equal("version", exception.ParamName); + } + + /// Keeps every host and target fact as supplied; none is inferred from another (audit F08). + [Fact] + public void PlatformInfoKeepsEveryObservedFact() + { + CheatEngineRuntimePlatformInfo platform = new(CheatEngineOperatingSystem.Windows, + CheatEngineArchitecture.X64, PointerSize.Bit64, TargetBackend.CEServer, CheatEngineArchitecture.Arm64, + PointerSize.Bit64, TargetAbi.Unix, true, 8); + + Assert.Equal(CheatEngineOperatingSystem.Windows, platform.HostOperatingSystem); + Assert.Equal(CheatEngineArchitecture.X64, platform.HostArchitecture); + Assert.Equal(PointerSize.Bit64, platform.CheatEngineBitness); + Assert.Equal(TargetBackend.CEServer, platform.TargetBackend); + Assert.Equal(CheatEngineArchitecture.Arm64, platform.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, platform.TargetBitness); + Assert.Equal(TargetAbi.Unix, platform.TargetAbi); + Assert.True(platform.TargetIsAndroid); + Assert.Equal(8, platform.ConfiguredPointerSizeBytes); + Assert.False(platform.ConfiguredPointerSizeDiffersFromBitness); } - /// Rejects absent capability collections instead of accepting an incomplete runtime snapshot. + /// The default platform keeps every fact unknown. [Fact] - public void SnapshotRejectsNullCapabilityCollections() - { - ArgumentNullException sdkCapabilities = Assert.Throws(() => - new CheatEngineRuntimeSnapshot( - 42, - CreateVersionInfo(), - CreatePlatformInfo(), - Null(), - ClientCapabilities.Empty)); - ArgumentNullException clientCapabilities = Assert.Throws(() => - new CheatEngineRuntimeSnapshot( - 42, - CreateVersionInfo(), - CreatePlatformInfo(), - RuntimeCapabilities.Empty, - Null())); - - Assert.Equal("sdkCapabilities", sdkCapabilities.ParamName); - Assert.Equal("clientCapabilities", clientCapabilities.ParamName); - } - - /// Rejects target pointer widths that disagree with the observed target architecture. + public void DefaultPlatformInfoKeepsEveryFactUnknown() + { + CheatEngineRuntimePlatformInfo platform = default; + + Assert.Equal(CheatEngineOperatingSystem.Unknown, platform.HostOperatingSystem); + Assert.Equal(CheatEngineArchitecture.Unknown, platform.HostArchitecture); + Assert.Equal(PointerSize.Unknown, platform.CheatEngineBitness); + Assert.Equal(TargetBackend.Unknown, platform.TargetBackend); + Assert.Equal(PointerSize.Unknown, platform.TargetBitness); + Assert.Null(platform.TargetIsAndroid); + Assert.Null(platform.ConfiguredPointerSizeBytes); + Assert.Null(platform.ConfiguredPointerSizeDiffersFromBitness); + } + + /// Rejects a known target bitness that disagrees with a known target architecture. [Theory] [InlineData(CheatEngineArchitecture.X64, 4)] [InlineData(CheatEngineArchitecture.X86, 8)] - public void PlatformInfoRejectsPointerSizeThatDoesNotMatchTargetArchitecture( + [InlineData(CheatEngineArchitecture.Arm64, 4)] + [InlineData(CheatEngineArchitecture.Arm32, 8)] + public void PlatformInfoRejectsABitnessThatDoesNotMatchTheTargetArchitecture( CheatEngineArchitecture targetArchitecture, int pointerBytes) { - ArgumentException exception = Assert.Throws(() => new CheatEngineRuntimePlatformInfo( - CheatEngineArchitecture.X64, - targetArchitecture, - new PointerSize(pointerBytes), - TargetAbi.Windows)); + ArgumentException exception = Assert.Throws(() => CreatePlatformInfo( + TargetBackend.LocalProcess, targetArchitecture, new PointerSize(pointerBytes), null)); - Assert.Equal("targetPointerSize", exception.ParamName); + Assert.Equal("targetBitness", exception.ParamName); } - /// Accepts unknown architecture and pointer-size facts without inventing a target platform. + /// Keeps a known bitness when the ISA could not be derived (Q32: never infer the ISA from the width). [Fact] - public void PlatformInfoAcceptsConsistentlyUnknownTargetArchitectureAndPointerSize() + [Trait("Qualification", "Q32")] + public void PlatformInfoAcceptsAnUnknownIsaWithAKnownBitness() { - CheatEngineRuntimePlatformInfo platform = new( - CheatEngineArchitecture.Unknown, - CheatEngineArchitecture.Unknown, - PointerSize.Unknown, - TargetAbi.Unknown); + CheatEngineRuntimePlatformInfo platform = CreatePlatformInfo(TargetBackend.LocalProcess, + CheatEngineArchitecture.Unknown, PointerSize.Bit64, null); Assert.Equal(CheatEngineArchitecture.Unknown, platform.TargetArchitecture); - Assert.Equal(PointerSize.Unknown, platform.TargetPointerSize); + Assert.Equal(PointerSize.Bit64, platform.TargetBitness); } - /// Rejects the default version-info value before it can produce a partially initialized snapshot. - [Fact] - public void SnapshotRejectsDefaultVersionInfo() + /// Keeps a known ISA when the bitness was not observed. + [Theory] + [InlineData(CheatEngineArchitecture.X86)] + [InlineData(CheatEngineArchitecture.X64)] + [InlineData(CheatEngineArchitecture.Arm32)] + [InlineData(CheatEngineArchitecture.Arm64)] + public void PlatformInfoAcceptsAKnownIsaWithAnUnknownBitness(CheatEngineArchitecture architecture) { - ArgumentNullException exception = Assert.Throws(() => new CheatEngineRuntimeSnapshot( - 42, - default, - CreatePlatformInfo(), - RuntimeCapabilities.Empty, - ClientCapabilities.Empty)); + CheatEngineRuntimePlatformInfo platform = CreatePlatformInfo(TargetBackend.LocalProcess, architecture, + PointerSize.Unknown, 4); - Assert.Equal("versionInfo.ClientAssemblyVersion", exception.ParamName); + Assert.Equal(architecture, platform.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, platform.TargetBitness); + Assert.Null(platform.ConfiguredPointerSizeDiffersFromBitness); } - private static CheatEngineRuntimeSnapshot Create(double? observedVersion = 7.7d, long epoch = 42) + /// Keeps Cheat Engine's configured pointer size separate from the bitness (Q31, spike C3 D3). + [Theory] + [Trait("Qualification", "Q31")] + [InlineData(4, true)] + [InlineData(2, true)] + [InlineData(8, false)] + public void PlatformInfoKeepsARawConfiguredPointerSizeApartFromTheBitness(int configured, bool differs) { - return new CheatEngineRuntimeSnapshot( - epoch, - CreateVersionInfo(observedVersion), - CreatePlatformInfo(), - RuntimeCapabilities.Empty, - ClientCapabilities.Empty); + CheatEngineRuntimePlatformInfo platform = CreatePlatformInfo(TargetBackend.LocalProcess, + CheatEngineArchitecture.X64, PointerSize.Bit64, configured); + + Assert.Equal(PointerSize.Bit64, platform.TargetBitness); + Assert.Equal(configured, platform.ConfiguredPointerSizeBytes); + Assert.Equal(configured switch + { + 4 => PointerSize.Bit32, + 8 => PointerSize.Bit64, + _ => PointerSize.Unknown + }, platform.ConfiguredPointerSize); + Assert.Equal(differs, platform.ConfiguredPointerSizeDiffersFromBitness); } - private static CheatEngineRuntimeVersionInfo CreateVersionInfo(double? observedVersion = 7.7d) + private static CheatEngineRuntimeVersionInfo CreateVersionInfo(CheatEngineVersion? observedVersion) { - return new CheatEngineRuntimeVersionInfo( - observedVersion, - CheatEngineVersion.Ce77010621, - new Version(0, 1, 0), - new Version(1, 0, 0)); + return new CheatEngineRuntimeVersionInfo(observedVersion, CheatEngineVersion.Ce77010621, new Version(0, 1, 0), + new Version(1, 0, 0), null, false); } private static CheatEngineRuntimePlatformInfo CreatePlatformInfo() { - return new CheatEngineRuntimePlatformInfo( - CheatEngineArchitecture.X64, - CheatEngineArchitecture.Unknown, - PointerSize.Unknown, - TargetAbi.Windows); + return CreatePlatformInfo(TargetBackend.Unknown, CheatEngineArchitecture.Unknown, PointerSize.Unknown, null); + } + + private static CheatEngineRuntimePlatformInfo CreatePlatformInfo(TargetBackend backend, + CheatEngineArchitecture architecture, PointerSize bitness, int? configured) + { + return new CheatEngineRuntimePlatformInfo(CheatEngineOperatingSystem.Windows, CheatEngineArchitecture.X64, + PointerSize.Bit64, backend, architecture, bitness, TargetAbi.Windows, false, configured); } private static T Null() where T : class diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs index c7eee6f..88f7505 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs @@ -7,14 +7,10 @@ public sealed class ClientCapabilitiesTests [Fact] public void CollectionPreservesDistinctObservedCapabilitiesAndTheirReasons() { - ClientCapabilityAvailability unsafeLua = new( - ClientCapabilityId.UnsafeLuaExecution, - ClientCapabilityAvailabilityState.Available, - "Explicit opt-in."); - ClientCapabilityAvailability valueScanning = new( - ClientCapabilityId.ValueScanning, - ClientCapabilityAvailabilityState.Unavailable, - "Live ownership gate pending."); + ClientCapabilityAvailability unsafeLua = Create(ClientCapabilityId.UnsafeLuaExecution, + ClientCapabilityEvidenceState.Satisfied, "Explicit opt-in."); + ClientCapabilityAvailability valueScanning = Create(ClientCapabilityId.ValueScanning, + ClientCapabilityEvidenceState.Missing, "Live ownership gate pending."); ClientCapabilities capabilities = ClientCapabilities.Create([unsafeLua, valueScanning]); @@ -28,6 +24,7 @@ public void CollectionPreservesDistinctObservedCapabilitiesAndTheirReasons() Assert.Equal(valueScanning, foundValueScanning); Assert.False(foundValueScanning.IsAvailable); Assert.True(foundValueScanning.IsKnown); + Assert.Equal("Live ownership gate pending.", foundValueScanning.Reason); } [Fact] @@ -85,23 +82,28 @@ public void EvidenceKeepsMissingFaultedAndMalformedHostPrerequisitesDistinct( [Fact] public void CollectionRejectsDuplicateCapabilityIdentifiers() { - ClientCapabilityAvailability observation = new( - ClientCapabilityId.ValueScanning, - ClientCapabilityAvailabilityState.Unavailable, - "Live ownership gate pending."); + ClientCapabilityAvailability observation = Create(ClientCapabilityId.ValueScanning, + ClientCapabilityEvidenceState.Missing, "Live ownership gate pending."); Assert.Throws(() => ClientCapabilities.Create([observation, observation])); } [Theory] - [InlineData(3)] - [InlineData(255)] - public void AvailabilityRejectsUndefinedStates(byte state) + [InlineData(5)] + [InlineData(-1)] + public void EvidenceGateRejectsUndefinedStates(int state) { - Assert.Throws(() => new ClientCapabilityAvailability( - ClientCapabilityId.ValueScanning, - (ClientCapabilityAvailabilityState) state, - "Invalid test state.")); + Assert.Throws(() => new ClientCapabilityEvidenceGate( + (ClientCapabilityEvidenceState) state, "Invalid test state.")); + } + + [Fact] + public void AvailabilityRejectsAnEmptyCapabilityIdentifier() + { + ClientCapabilityEvidenceGate gate = new(ClientCapabilityEvidenceState.Unknown, "Not established."); + + Assert.Throws(() => new ClientCapabilityAvailability(default, + new ClientCapabilityEvidence(gate, gate, gate, gate, gate, gate))); } [Fact] @@ -115,24 +117,25 @@ public void CapabilityIdentifierRejectsBlankInputAndFormatsItsStableValue() Assert.Equal(capability.Value, capability.ToString()); } + private static ClientCapabilityAvailability Create(ClientCapabilityId capability, ClientCapabilityEvidenceState state, + string reason) + { + ClientCapabilityEvidenceGate gate = new(state, reason); + return new ClientCapabilityAvailability(capability, new ClientCapabilityEvidence(gate, gate, gate, gate, gate, + gate)); + } + [Fact] public void AdvancedDomainCapabilityIdentifiersAreStableAndDistinct() { ClientCapabilityId[] capabilities = [ ClientCapabilityId.Allocations, - ClientCapabilityId.Assembly, - ClientCapabilityId.RemoteExecution, - ClientCapabilityId.Debugger, - ClientCapabilityId.Hotkeys, - ClientCapabilityId.Timers, - ClientCapabilityId.Speed, - ClientCapabilityId.Hashing, - ClientCapabilityId.Dbvm + ClientCapabilityId.Assembly ]; - Assert.Equal(9, capabilities.Length); - Assert.Equal(9, capabilities.Select(static capability => capability.Value).Distinct().Count()); + Assert.Equal(2, capabilities.Length); + Assert.Equal(2, capabilities.Select(static capability => capability.Value).Distinct().Count()); Assert.All(capabilities, static capability => Assert.StartsWith("Client.", capability.Value)); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilityEvidenceTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilityEvidenceTests.cs index dd59c91..22049ab 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilityEvidenceTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilityEvidenceTests.cs @@ -4,15 +4,18 @@ namespace CheatEngine.Client.Abstractions.Tests.Runtime; public sealed class ClientCapabilityEvidenceTests { - public static IEnumerable EffectiveReasonPriorityCases + public static TheoryData EffectiveReasonPriorityCases { get { + TheoryData cases = []; foreach (ClientCapabilityEvidenceState state in new[] - { - ClientCapabilityEvidenceState.Missing, ClientCapabilityEvidenceState.Faulted, - ClientCapabilityEvidenceState.Malformed, ClientCapabilityEvidenceState.Unknown - }) + { + ClientCapabilityEvidenceState.Missing, ClientCapabilityEvidenceState.Faulted, + ClientCapabilityEvidenceState.Malformed, ClientCapabilityEvidenceState.Unknown + }) { ClientCapabilityAvailabilityState expectedAvailabilityState = state == ClientCapabilityEvidenceState.Missing @@ -21,9 +24,11 @@ public static IEnumerable EffectiveReasonPriorityCases foreach (ClientCapabilityEvidenceReasonCode expectedReasonCode in GetPriority(state)) { - yield return [state, expectedReasonCode, expectedAvailabilityState]; + cases.Add(state, expectedReasonCode, expectedAvailabilityState); } } + + return cases; } } @@ -97,38 +102,37 @@ public void AllSatisfiedEvidenceUsesTheLifetimeReasonCode() Assert.Equal(ClientCapabilityEvidenceReasonCode.Lifetime, evidence.EffectiveReasonCode); Assert.Equal(ReasonFor(ClientCapabilityEvidenceReasonCode.Lifetime), evidence.EffectiveReason); - Assert.True(evidence.IsExecutable); Assert.Equal(ClientCapabilityAvailabilityState.Available, evidence.AvailabilityState); } - [Theory] - [InlineData(ClientCapabilityAvailabilityState.Available, ClientCapabilityEvidenceReasonCode.Lifetime)] - [InlineData(ClientCapabilityAvailabilityState.Unavailable, ClientCapabilityEvidenceReasonCode.Lifetime)] - [InlineData(ClientCapabilityAvailabilityState.Unknown, ClientCapabilityEvidenceReasonCode.Host)] - public void LegacyAvailabilityConstructionRetainsStateAndDisplayReason( - ClientCapabilityAvailabilityState state, - ClientCapabilityEvidenceReasonCode expectedReasonCode) + [Fact] + public void DefaultEvidenceHasNoEffectiveReasonAndCannotBackAnAvailability() { - const string reason = "Legacy display reason."; - ClientCapabilityAvailability availability = new(ClientCapabilityId.ProcessSelection, state, reason); - - Assert.Equal(state, availability.State); - Assert.Equal(expectedReasonCode, availability.Evidence.EffectiveReasonCode); - Assert.Equal(reason, availability.Evidence.EffectiveReason); - Assert.Equal(reason, availability.Reason); - Assert.Equal(state == ClientCapabilityAvailabilityState.Available, availability.IsAvailable); - Assert.Equal(state != ClientCapabilityAvailabilityState.Unknown, availability.IsKnown); + ClientCapabilityEvidence evidence = default; + + Assert.Equal(ClientCapabilityEvidenceReasonCode.Unknown, evidence.EffectiveReasonCode); + Assert.Equal(string.Empty, evidence.EffectiveReason); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, evidence.AvailabilityState); + Assert.Equal("evidence", Assert.Throws(() => + new ClientCapabilityAvailability(ClientCapabilityId.ProcessSelection, evidence)).ParamName); } [Fact] - public void EffectiveReasonCodesUseStableUnderlyingValues() + public void TheCapabilityEnumsAreIntBackedWithUnknownAsZero() { - Assert.Equal((byte) 0, (byte) ClientCapabilityEvidenceReasonCode.Implementation); - Assert.Equal((byte) 1, (byte) ClientCapabilityEvidenceReasonCode.Package); - Assert.Equal((byte) 2, (byte) ClientCapabilityEvidenceReasonCode.Host); - Assert.Equal((byte) 3, (byte) ClientCapabilityEvidenceReasonCode.LiveQualification); - Assert.Equal((byte) 4, (byte) ClientCapabilityEvidenceReasonCode.Policy); - Assert.Equal((byte) 5, (byte) ClientCapabilityEvidenceReasonCode.Lifetime); + // The 1.x enum charter: int, explicit values, Unknown = 0 (OutcomeEnumConventionTests). + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(ClientCapabilityEvidenceReasonCode))); + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(ClientCapabilityEvidenceState))); + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(ClientCapabilityAvailabilityState))); + Assert.Equal(0, (int) ClientCapabilityEvidenceReasonCode.Unknown); + Assert.Equal(1, (int) ClientCapabilityEvidenceReasonCode.Implementation); + Assert.Equal(2, (int) ClientCapabilityEvidenceReasonCode.Package); + Assert.Equal(3, (int) ClientCapabilityEvidenceReasonCode.Host); + Assert.Equal(4, (int) ClientCapabilityEvidenceReasonCode.LiveQualification); + Assert.Equal(5, (int) ClientCapabilityEvidenceReasonCode.Policy); + Assert.Equal(6, (int) ClientCapabilityEvidenceReasonCode.Lifetime); + Assert.Equal(0, (int) ClientCapabilityEvidenceState.Unknown); + Assert.Equal(0, (int) ClientCapabilityAvailabilityState.Unknown); } private static ClientCapabilityEvidence CreateEvidenceForPriority( @@ -154,7 +158,7 @@ private static ClientCapabilityEvidenceGate CreateGate( int expectedPriorityIndex) { ClientCapabilityEvidenceState gateState = state == ClientCapabilityEvidenceState.Satisfied || - Array.IndexOf(priority, code) < expectedPriorityIndex + Array.IndexOf(priority, code) < expectedPriorityIndex ? ClientCapabilityEvidenceState.Satisfied : state; return new ClientCapabilityEvidenceGate(gateState, ReasonFor(code)); diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs index 124dd1b..e0d3e52 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs @@ -1,7 +1,5 @@ using CheatEngine.Client.Scanning; -using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Inspection; -using CheatEngine.SDK.Engine.Scanning.Aob; namespace CheatEngine.Client.Abstractions.Tests.Scanning; @@ -52,53 +50,92 @@ public void PatternTryParseNormalizesWildcardOnlyPatternsWithoutThrowing() } [Fact] - public void RequestPreservesPatternOptionsLimitAndModule() + public void RequestPreservesPatternLimitModuleRangeProtectionAndAlignment() { AobPattern pattern = new("90 90"); - AobScanOptions options = new("-w+x-c", default, null); ModuleName module = new("game.exe"); AobScanRange range = new(0x400000, 0x4FFFFF); + ScanProtectionFilter protection = new(ScanProtectionRequirement.Required, ScanProtectionRequirement.Excluded, + ScanProtectionRequirement.Any); - AobScanRequest request = new(pattern, options, 2, module, range); + AobScanRequest request = new(pattern, 2, module, range, protection, ScanAlignment.AlignedTo(4)); Assert.Equal(pattern, request.Pattern); - Assert.Equal("+X-C-W", request.Options.ProtectionFlags); Assert.Equal(2, request.MaximumResults); Assert.Equal(module, request.Module); Assert.Equal(range, request.Range); + Assert.Equal(protection, request.Protection); + Assert.Equal(ScanAlignment.AlignedTo(4), request.Alignment); } [Fact] - public void RequestNormalizesAlignmentParametersBeforeTheyReachTheScanner() + public void RequestDefaultsToAnUnspecifiedFilterAndNoAlignment() { - AobScanRequest aligned = new( - new AobPattern("90"), new AobScanOptions(null, FastScanMethod.Aligned, "00016"), 1); - AobScanRequest lastDigits = new( - new AobPattern("90"), new AobScanOptions(null, FastScanMethod.LastDigits, "f0"), 1); + AobScanRequest request = new(new AobPattern("90"), 1); - Assert.Equal("16", aligned.Options.AlignmentParameter); - Assert.Equal("F0", lastDigits.Options.AlignmentParameter); + Assert.True(request.Protection.IsUnspecified); + Assert.Equal(ScanAlignment.None, request.Alignment); + Assert.Equal(ScanAlignmentMode.None, request.Alignment.Mode); + Assert.Null(request.Module); + Assert.Null(request.Range); + } + + [Fact] + public void AlignmentFactoriesNormalizeTheirArgument() + { + ScanAlignment aligned = ScanAlignment.AlignedTo(16); + ScanAlignment lastDigits = ScanAlignment.LastDigits("f0"); + + Assert.Equal(ScanAlignmentMode.AlignedTo, aligned.Mode); + Assert.Equal(16, aligned.Divisor); + Assert.Null(aligned.Digits); + Assert.Equal(ScanAlignmentMode.LastDigits, lastDigits.Mode); + Assert.Equal("F0", lastDigits.Digits); + Assert.Equal(0, lastDigits.Divisor); + Assert.Equal(ScanAlignment.LastDigits("F0"), lastDigits); } [Theory] - [InlineData("X")] - [InlineData("+X+X")] - [InlineData("+Q")] - [InlineData("+X ")] - public void RequestRejectsMalformedProtectionExpressions(string protection) + [InlineData(0)] + [InlineData(-4)] + public void AlignedToRejectsANonPositiveDivisor(int divisor) { - Assert.Throws(() => new AobScanRequest( - new AobPattern("90"), new AobScanOptions(protection, FastScanMethod.NotAligned, null), 1)); + Assert.Throws(() => ScanAlignment.AlignedTo(divisor)); } [Theory] - [InlineData("0")] - [InlineData("-4")] + [InlineData("")] [InlineData("FFGG")] - public void RequestRejectsMalformedAlignmentParameters(string parameter) + [InlineData("0x10")] + [InlineData("12345678901234567")] + public void LastDigitsRejectsMalformedDigits(string digits) + { + Assert.Throws(() => ScanAlignment.LastDigits(digits)); + } + + [Fact] + public void LastDigitsRejectsNull() + { + Assert.Throws(() => ScanAlignment.LastDigits(null!)); + } + + [Theory] + [InlineData(4, 0, 0)] + [InlineData(0, 4, 0)] + [InlineData(0, 0, -1)] + public void ProtectionFilterRejectsAnUndefinedRequirement(int executable, int copyOnWrite, int writable) + { + Assert.Throws(() => new ScanProtectionFilter( + (ScanProtectionRequirement) executable, (ScanProtectionRequirement) copyOnWrite, + (ScanProtectionRequirement) writable)); + } + + [Fact] + public void ProtectionFilterIsUnspecifiedOnlyWhenEveryFlagIs() { - Assert.Throws(() => new AobScanRequest( - new AobPattern("90"), new AobScanOptions(null, FastScanMethod.Aligned, parameter), 1)); + Assert.True(default(ScanProtectionFilter).IsUnspecified); + Assert.False(new ScanProtectionFilter(ScanProtectionRequirement.Unspecified, + ScanProtectionRequirement.Unspecified, ScanProtectionRequirement.Any).IsUnspecified); } [Fact] @@ -118,15 +155,13 @@ public void RangeIsInclusiveAtBothBoundaries() [InlineData(-1)] public void RequestRejectsNonPositiveMaterializationLimit(int maximumResults) { - Assert.Throws(() => new AobScanRequest( - new AobPattern("90"), AobScanOptions.Default, maximumResults)); + Assert.Throws(() => new AobScanRequest(new AobPattern("90"), maximumResults)); } [Fact] public void RequestRejectsDefaultPatternBeforeReachingTheScanner() { - Assert.Throws(() => new AobScanRequest( - default, AobScanOptions.Default, 1)); + Assert.Throws(() => new AobScanRequest(default, 1)); } [Fact] diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/PatternScanOutcomeTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/PatternScanOutcomeTests.cs new file mode 100644 index 0000000..3088778 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/PatternScanOutcomeTests.cs @@ -0,0 +1,149 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Abstractions.Tests.Scanning; + +public sealed class PatternScanOutcomeTests +{ + private const PatternScanScope Scope = PatternScanScope.GlobalHostScanWithManagedFilter; + + private static readonly TimeSpan Elapsed = TimeSpan.FromMilliseconds(3); + + [Fact] + public void MetricsConstructorRejectsANegativeMaterializedCountNegativeDurationsAndUndefinedScopes() + { + Assert.Throws(() => new PatternScanMetrics(Scope, 1, 1, 0, -1, 0, 0, 0, true, + Elapsed, Elapsed)); + Assert.Throws(() => new PatternScanMetrics(Scope, 1, 1, 0, 1, 0, 0, 0, true, + TimeSpan.FromTicks(-1), Elapsed)); + Assert.Throws(() => new PatternScanMetrics(Scope, 1, 1, 0, 1, 0, 0, 0, true, + Elapsed, TimeSpan.FromTicks(-1))); + Assert.Throws(() => new PatternScanMetrics((PatternScanScope) 42, 1, 1, 0, 1, 0, + 0, 0, true, Elapsed, Elapsed)); + } + + /// Every documented invariant between the counts is enforced. + [Theory] + [InlineData(2UL, 3UL, 0UL, 0, 0UL, 0UL, 0UL, false)] + [InlineData(5UL, 3UL, 0UL, 0, 0UL, 0UL, 1UL, false)] + [InlineData(5UL, 3UL, 4UL, 0, 0UL, 0UL, 2UL, false)] + [InlineData(5UL, 3UL, 2UL, 2, 0UL, 0UL, 2UL, false)] + [InlineData(5UL, 5UL, 2UL, 1, 2UL, 1UL, 0UL, true)] + [InlineData(5UL, 3UL, 1UL, 1, 0UL, 0UL, 2UL, true)] + [InlineData(ulong.MaxValue, ulong.MaxValue, ulong.MaxValue, 1, 0UL, 0UL, 0UL, false)] + public void MetricsConstructorRejectsInconsistentCounts(ulong host, ulong examined, ulong filteredOut, + int materialized, ulong belowStart, ulong atOrAfterStop, ulong unread, bool exact) + { + Assert.Throws(() => new PatternScanMetrics(Scope, host, examined, filteredOut, materialized, + belowStart, atOrAfterStop, unread, exact, Elapsed, Elapsed)); + } + + [Fact] + public void MetricsConstructorKeepsTheValidatedValues() + { + PatternScanMetrics metrics = new(PatternScanScope.HostBoundedRange, 10, 6, 3, 2, 1, 1, 4, false, + TimeSpan.FromMilliseconds(70), TimeSpan.FromMilliseconds(8)); + + Assert.Equal(PatternScanScope.HostBoundedRange, metrics.Scope); + Assert.Equal(10UL, metrics.HostResultCount); + Assert.Equal(6UL, metrics.ExaminedCount); + Assert.Equal(3UL, metrics.FilteredOutCount); + Assert.Equal(2, metrics.MaterializedCount); + Assert.Equal(1UL, metrics.BelowStartSkippedCount); + Assert.Equal(1UL, metrics.AtOrAfterStopSkippedCount); + Assert.Equal(4UL, metrics.UnreadHostRowCount); + Assert.False(metrics.InBoundsCountIsExact); + Assert.Equal(TimeSpan.FromMilliseconds(70), metrics.HostScanElapsed); + Assert.Equal(TimeSpan.FromMilliseconds(8), metrics.MaterializationElapsed); + Assert.Equal(PatternScanScope.Unknown, default(PatternScanMetrics).Scope); + } + + [Fact] + public void OutcomeRequiresExactlyOneOfResultOrFailure() + { + AobScanResult result = new(ImmutableArray.Create
(0x401000), false); + CheatEngineFailure failure = new(CheatEngineFailureKind.IndeterminateHostResult, "Patterns.Scan", + "no list", null, CheatEngineHostEffect.Completed); + PatternScanMetrics metrics = Metrics(materialized: 1); + + Assert.Throws(() => Outcome(null, null, null)); + Assert.Throws(() => Outcome(result, failure, metrics)); + Assert.Throws(() => Outcome(null, default(CheatEngineFailure), null)); + + PatternScanOutcome success = new(result, null, metrics, PatternScanHostOutcomeKind.Matches, + PatternScanRouteReason.TargetIdentityNotQualified, false); + PatternScanOutcome noList = new(null, failure, null, PatternScanHostOutcomeKind.NoResult, + PatternScanRouteReason.UnscopedRequest, false); + + Assert.True(success.IsSuccess); + Assert.Equal(result, success.Result); + Assert.Equal(metrics, success.Metrics); + Assert.Equal(PatternScanHostOutcomeKind.Matches, success.HostOutcome); + Assert.Equal(PatternScanRouteReason.TargetIdentityNotQualified, success.RouteReason); + Assert.False(success.TargetIdentityVerified); + Assert.False(noList.IsSuccess); + Assert.Equal(failure, noList.Failure); + Assert.Null(noList.Metrics); + Assert.Equal(PatternScanHostOutcomeKind.NoResult, noList.HostOutcome); + } + + [Fact] + public void SuccessfulOutcomeRequiresMetricsThatMatchTheCopiedResult() + { + AobScanResult result = new(ImmutableArray.Create
(0x401000, 0x401010), false); + + Assert.Throws(() => Outcome(result, null, null)); + Assert.Throws(() => Outcome(result, null, Metrics(materialized: 1))); + Assert.True(Outcome(result, null, Metrics(materialized: 2)).IsSuccess); + } + + [Theory] + [InlineData(PatternScanHostOutcomeKind.NoResult, PatternScanRouteReason.UnscopedRequest)] + [InlineData(PatternScanHostOutcomeKind.Unknown, PatternScanRouteReason.UnscopedRequest)] + [InlineData(PatternScanHostOutcomeKind.Matches, PatternScanRouteReason.Unknown)] + public void ASuccessReportsAMatchesOrNoMatchesHostOutcomeAndItsRoute(PatternScanHostOutcomeKind hostOutcome, + PatternScanRouteReason routeReason) + { + AobScanResult result = new(ImmutableArray.Create
(0x401000), false); + + Assert.Throws(() => + new PatternScanOutcome(result, null, Metrics(materialized: 1), hostOutcome, routeReason, false)); + } + + [Fact] + public void AFailureNeverReportsAVerifiedTarget() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.TargetChanged, "Patterns.Scan", "changed", null, + CheatEngineHostEffect.Completed); + + Assert.Throws(() => new PatternScanOutcome(null, failure, null, + PatternScanHostOutcomeKind.Matches, PatternScanRouteReason.UnscopedRequest, true)); + } + + [Fact] + public void OutcomeRejectsUndefinedHostOutcomesAndRouteReasons() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.InvalidHostResult, "Patterns.Scan", "bad", null, + CheatEngineHostEffect.Completed); + + Assert.Throws(() => new PatternScanOutcome(null, failure, null, + (PatternScanHostOutcomeKind) 99, PatternScanRouteReason.Unknown, false)); + Assert.Throws(() => new PatternScanOutcome(null, failure, null, + PatternScanHostOutcomeKind.Unknown, (PatternScanRouteReason) 99, false)); + } + + private static PatternScanOutcome Outcome(AobScanResult? result, CheatEngineFailure? failure, + PatternScanMetrics? metrics) + { + return new PatternScanOutcome(result, failure, metrics, PatternScanHostOutcomeKind.Matches, + PatternScanRouteReason.UnscopedRequest, false); + } + + private static PatternScanMetrics Metrics(int materialized) + { + return new PatternScanMetrics(Scope, 5, 4, 1, materialized, 0, 0, 1, false, Elapsed, Elapsed); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs index b98728a..e5155c9 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Globalization; using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Values; @@ -8,36 +9,213 @@ namespace CheatEngine.Client.Abstractions.Tests.Scanning; public sealed class ValueScanContractTests { [Fact] - public void SessionStateUsesTheClientLifecycleWithoutSdkHandles() + public void SessionStateStartsWithUnknownAndFollowsTheClientLifecycle() { - Assert.Equal(0, (int) ValueScanSessionState.Created); - Assert.Equal(1, (int) ValueScanSessionState.Scanning); - Assert.Equal(2, (int) ValueScanSessionState.ResultsReady); - Assert.Equal(3, (int) ValueScanSessionState.Invalidated); - Assert.Equal(4, (int) ValueScanSessionState.Disposed); + Assert.Equal(0, (int) ValueScanSessionState.Unknown); + Assert.Equal(1, (int) ValueScanSessionState.Created); + Assert.Equal(2, (int) ValueScanSessionState.Scanning); + Assert.Equal(3, (int) ValueScanSessionState.ResultsReady); + Assert.Equal(4, (int) ValueScanSessionState.Invalidated); + Assert.Equal(5, (int) ValueScanSessionState.Closed); + Assert.Equal(0, (int) ValueScanInvalidationKind.Unknown); + Assert.Equal(1, (int) ValueScanInvalidationKind.None); } [Fact] - public void ReadRequestRejectsNegativeStartAndNonPositiveMaterializationLimit() + public void ReadRequestRejectsNegativeStartAndNonPositiveCountAndKeepsAWideStart() { + ValueScanReadRequest wide = new((long) int.MaxValue + 1, 1); + Assert.Throws(() => new ValueScanReadRequest(-1, 1)); Assert.Throws(() => new ValueScanReadRequest(0, 0)); + Assert.Equal((long) int.MaxValue + 1, wide.StartIndex); + } + + [Fact] + public void PageNormalizesADefaultArrayAndLocatesTheNextPage() + { + ValueScanPage empty = new(0, 0, default); + ValueScanPage first = new(0, 3, [new ValueScanMatch(new Address(0x1000), "7"), new ValueScanMatch( + new Address(0x2000), "8")]); + ValueScanPage last = new(2, 3, [new ValueScanMatch(new Address(0x3000), "9")]); + + Assert.True(empty.Matches.IsEmpty); + Assert.Equal(ImmutableArray.Empty, empty.Matches); + Assert.False(empty.HasMore); + Assert.Equal(2, first.NextStartIndex); + Assert.True(first.HasMore); + Assert.Equal(3, last.NextStartIndex); + Assert.False(last.HasMore); + Assert.Throws(() => new ValueScanPage(-1, 0, [])); + } + + [Fact] + public void MatchRejectsANullValueText() + { + ValueScanMatch match = new(new Address(0x401000), "100"); + + Assert.Throws(() => new ValueScanMatch(Address.Zero, null!)); + Assert.Equal(new Address(0x401000), match.Address); + Assert.Equal("100", match.ValueText); + } + + [Fact] + public void ValueFactoriesChooseTheTypeAndFormatTheInvariantText() + { + CultureInfo previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + try + { + Assert.Equal((ValueScanValueType.Integer8, "255"), Describe(ValueScanValue.FromByte(255))); + Assert.Equal((ValueScanValueType.Integer16, "-2"), Describe(ValueScanValue.FromInt16(-2))); + Assert.Equal((ValueScanValueType.Integer32, "-100000"), Describe(ValueScanValue.FromInt32(-100_000))); + Assert.Equal((ValueScanValueType.Integer64, "9223372036854775807"), + Describe(ValueScanValue.FromInt64(long.MaxValue))); + Assert.Equal((ValueScanValueType.SingleFloat, "1.50"), Describe(ValueScanValue.FromSingle(1.5f, 2))); + Assert.Equal((ValueScanValueType.DoubleFloat, "-0.250"), Describe(ValueScanValue.FromDouble(-0.25, 3))); + Assert.Equal((ValueScanValueType.Utf8String, "Gold"), Describe(ValueScanValue.FromUtf8String("Gold"))); + Assert.Equal((ValueScanValueType.Utf16String, "Gold"), Describe(ValueScanValue.FromUtf16String("Gold"))); + Assert.Equal((ValueScanValueType.ByteArray, "48 8B 05"), + Describe(ValueScanValue.FromBytes([0x48, 0x8B, 0x05]))); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + + Assert.True(ValueScanValue.FromDouble(2, 0).IsNumeric); + Assert.False(ValueScanValue.FromBytes([1]).IsNumeric); + Assert.Null(default(ValueScanValue).Text); + } + + [Theory] + [InlineData(0.00001, 5, "0.00001")] + [InlineData(0.00001, 7, "0.0000100")] + [InlineData(100.0, 0, "100")] + [InlineData(100.0, 2, "100.00")] + [InlineData(1048576.0, 1, "1048576.0")] + [InlineData(2.5, 15, "2.500000000000000")] + public void FloatingPointValuesAreWrittenInFixedPointWithTheRequestedDecimals(double value, int decimals, + string expected) + { + // The number of decimals is the precision of Cheat Engine's rounded exact comparison: exponent notation, which a + // round-trip format produces for 0.00001 ("1E-05"), would carry none. + Assert.Equal(expected, ValueScanValue.FromDouble(value, decimals).Text); + Assert.Equal(expected, ValueScanValue.FromSingle((float) value, decimals).Text); + } + + [Fact] + public void DoublesFarFromOneAreNeverWrittenInExponentNotation() + { + Assert.Equal("100000000000000000000.0", ValueScanValue.FromDouble(1e20, 1).Text); + Assert.Equal("0.000000000000000", ValueScanValue.FromDouble(1e-20, 15).Text); + } + + [Fact] + public void ValueFactoriesRejectValuesCheatEngineCannotParse() + { + Assert.Throws(() => ValueScanValue.FromSingle(float.NaN, 2)); + Assert.Throws(() => ValueScanValue.FromDouble(double.PositiveInfinity, 2)); + Assert.Throws(() => ValueScanValue.FromDouble(1, -1)); + Assert.Throws(() => ValueScanValue.FromSingle(1, 16)); + Assert.Throws(() => ValueScanValue.FromUtf8String(string.Empty)); + Assert.Throws(() => ValueScanValue.FromUtf16String(null!)); + Assert.Throws(() => ValueScanValue.FromBytes([])); + } + + [Fact] + public void FirstRequestFactoriesScanTheWholeAddressSpaceWithoutFilters() + { + ValueScanFirstRequest exact = ValueScanFirstRequest.Exact(ValueScanValue.FromUtf8String("Gold")); + ValueScanFirstRequest between = + ValueScanFirstRequest.Between(ValueScanValue.FromInt32(1), ValueScanValue.FromInt32(9)); + ValueScanFirstRequest unknown = ValueScanFirstRequest.UnknownInitialValue(ValueScanValueType.SingleFloat); + + Assert.Equal(ValueScanComparison.Exact, exact.Comparison); + Assert.Equal(ValueScanValueType.Utf8String, exact.ValueType); + Assert.Equal(Address.Zero, exact.StartAddress); + Assert.Equal(new Address(ulong.MaxValue), exact.StopAddress); + Assert.Equal(default, exact.Protection); + Assert.Equal(ScanAlignment.None, exact.Alignment); + Assert.Null(exact.UpperValue); + Assert.Equal(ValueScanComparison.Between, between.Comparison); + Assert.Equal("1", between.Value?.Text); + Assert.Equal("9", between.UpperValue?.Text); + Assert.Equal(ValueScanComparison.UnknownInitialValue, unknown.Comparison); + Assert.Equal(ValueScanValueType.SingleFloat, unknown.ValueType); + Assert.Null(unknown.Value); + } + + [Fact] + public void FirstRequestFactoriesRejectComparisonsCheatEngineDoesNotDefine() + { + Assert.Throws(() => ValueScanFirstRequest.Exact(default)); + Assert.Throws(() => + ValueScanFirstRequest.Between(ValueScanValue.FromInt32(1), ValueScanValue.FromInt64(9))); + Assert.Throws(() => + ValueScanFirstRequest.BiggerThan(ValueScanValue.FromUtf8String("Gold"))); + Assert.Throws(() => ValueScanFirstRequest.SmallerThan(ValueScanValue.FromBytes([1]))); + Assert.Throws(() => + ValueScanFirstRequest.UnknownInitialValue(ValueScanValueType.Utf16String)); } [Fact] - public void ResultPageNormalizesDefaultImmutableArrayWithoutChangingTheObservedCount() + public void FirstRequestNarrowsItsRangeFiltersAndAlignment() { - ValueScanPage page = new(42, default); + ScanProtectionFilter writable = new(ScanProtectionRequirement.Unspecified, ScanProtectionRequirement.Excluded, + ScanProtectionRequirement.Required); + ValueScanFirstRequest request = ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(100)) + .WithRange(new Address(0x1000), new Address(0x2000)) + .WithProtection(writable) + .WithAlignment(ScanAlignment.AlignedTo(4)); - Assert.Equal((ulong) 42, page.TotalCount); - Assert.True(page.Matches.IsEmpty); - Assert.Equal(ImmutableArray.Empty, page.Matches); + Assert.Equal(new Address(0x1000), request.StartAddress); + Assert.Equal(new Address(0x2000), request.StopAddress); + Assert.Equal(writable, request.Protection); + Assert.Equal(4, request.Alignment.Divisor); + Assert.Throws(() => request.WithRange(new Address(0x2000), new Address(0x2000))); } [Fact] - public void MatchRejectsNegativeIndexAndNullValue() + public void NextRequestFactoriesCarryAValueOnlyWhenTheyCompareOne() + { + ValueScanNextRequest exact = ValueScanNextRequest.Exact(ValueScanValue.FromInt32(95)); + ValueScanNextRequest decreasedBy = ValueScanNextRequest.DecreasedBy(ValueScanValue.FromInt32(5)); + + Assert.Equal(ValueScanComparison.Exact, exact.Comparison); + Assert.Equal("95", exact.Value?.Text); + Assert.Equal(ValueScanComparison.DecreasedBy, decreasedBy.Comparison); + Assert.All( + [ + ValueScanNextRequest.Increased(), ValueScanNextRequest.Decreased(), ValueScanNextRequest.Changed(), + ValueScanNextRequest.Unchanged() + ], + static request => + { + Assert.Null(request.Value); + Assert.Null(request.UpperValue); + }); + Assert.Throws(() => ValueScanNextRequest.IncreasedBy(ValueScanValue.FromUtf8String("a"))); + Assert.Throws(() => + ValueScanNextRequest.Between(ValueScanValue.FromInt16(1), ValueScanValue.FromInt32(2))); + } + + [Fact] + public void ScanAlignmentAndProtectionValidateTheirArguments() + { + Assert.Equal(0, ScanAlignment.None.Divisor); + Assert.Null(ScanAlignment.None.Digits); + Assert.Equal(ScanAlignmentMode.None, ScanAlignment.None.Mode); + Assert.Equal("0A", ScanAlignment.LastDigits("0a").Digits); + Assert.Throws(() => ScanAlignment.AlignedTo(0)); + Assert.Throws(() => ScanAlignment.LastDigits("0x10")); + Assert.Throws(() => ScanAlignment.LastDigits(new string('F', 17))); + Assert.Throws(() => new ScanProtectionFilter( + (ScanProtectionRequirement) 9, ScanProtectionRequirement.Any, ScanProtectionRequirement.Any)); + } + + private static (ValueScanValueType Type, string? Text) Describe(ValueScanValue value) { - Assert.Throws(() => new ValueScanMatch(-1, Address.Zero, "7")); - Assert.Throws(() => new ValueScanMatch(0, Address.Zero, null!)); + return (value.ValueType, value.Text); } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs deleted file mode 100644 index 40f91c0..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CheatEngine.Client.Speed; - -namespace CheatEngine.Client.Abstractions.Tests.Speed; - -public sealed class SpeedContractsTests -{ - [Fact] - public void MultiplierPreservesAFinitePositiveValue() - { - SpeedMultiplier multiplier = new(1.5); - - Assert.Equal(1.5, multiplier.Value); - } - - [Theory] - [InlineData(0d)] - [InlineData(-1d)] - [InlineData(double.PositiveInfinity)] - [InlineData(double.NegativeInfinity)] - [InlineData(double.NaN)] - public void MultiplierRejectsNonFiniteOrNonPositiveValues(double value) - { - Assert.Throws(() => new SpeedMultiplier(value)); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs index 844d985..4b60c9c 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs @@ -27,13 +27,14 @@ public void MemoryRecordCollectionRequestRetainsItsPositiveBound() Assert.Equal(32, request.MaximumItems); } - /// Represents a known record count without forcing records to be copied into the snapshot. + /// Treats a default record array as an empty copied table, never as an uninitialized one. [Fact] - public void CardinalityOnlyAddressTableSnapshotRetainsTheCountWithoutMaterializingRecords() + public void AddressTableSnapshotTreatsADefaultRecordArrayAsEmpty() { - AddressTableSnapshot snapshot = new(3); + AddressTableSnapshot snapshot = new(default(ImmutableArray)); - Assert.Equal(3, snapshot.RecordCount); + Assert.Equal(0, snapshot.RecordCount); + Assert.False(snapshot.Records.IsDefault); Assert.Empty(snapshot.Records); } @@ -58,6 +59,23 @@ public void MemoryRecordSearchRequiresAtLeastOneMeaningfulPredicate() Assert.Throws(() => new MemoryRecordSearch(addressExpression: string.Empty)); } + /// An undefined value type is a programming error in a search, a definition and an update alike. + [Fact] + public void EveryRecordRequestRejectsAnUndefinedValueType() + { + const VariableType undefined = (VariableType) 99; + + ArgumentOutOfRangeException search = + Assert.Throws(() => new MemoryRecordSearch(variableType: undefined)); + ArgumentOutOfRangeException definition = Assert.Throws(() => + new MemoryRecordDefinition("health", "game.exe+20", "100", undefined)); + ArgumentOutOfRangeException update = + Assert.Throws(() => new MemoryRecordUpdate(variableType: undefined)); + + Assert.All([search, definition, update], + static exception => Assert.Equal("variableType", exception.ParamName)); + } + /// Retains every supplied record-search predicate as one conjunctive request. [Fact] public void MemoryRecordSearchPreservesConjunctivePredicates() @@ -84,31 +102,70 @@ public void SnapshotCarriesCopiedRecordsAndCardinality() Assert.Equal(1, snapshot.RecordCount); MemoryRecordSnapshot onlyRecord = Assert.Single(snapshot.Records); Assert.Equal(record, onlyRecord); - Assert.True(onlyRecord.IsActive); - Assert.Equal(2, onlyRecord.ChildCount); + Assert.True(onlyRecord.State.IsActive); + Assert.Equal(2, onlyRecord.State.ChildCount); } - /// Forwards grouped content and state fields through the established record leaf properties. + /// Keeps each copied field once, in its content or state group. [Fact] - public void MemoryRecordSnapshotForwardsContentAndStateComponentsToItsExistingLeafProperties() + public void MemoryRecordSnapshotGroupsItsFieldsWithoutFlattenedCopies() { MemoryRecordId id = new(12); - Address address = Address.FromUInt64(0x1400); MemoryRecordContentSnapshot content = CreateContent(); - MemoryRecordStateSnapshot state = CreateState(address, true, 2); + MemoryRecordStateSnapshot state = CreateState(Address.FromUInt64(0x1400), true, 2); MemoryRecordSnapshot snapshot = CreateSnapshot(id, 0, content, state); Assert.Equal(id, snapshot.Id); Assert.Equal(0, snapshot.Index); Assert.Equal(content, snapshot.Content); Assert.Equal(state, snapshot.State); - Assert.Equal(content.Description, snapshot.Description); - Assert.Equal(content.AddressExpression, snapshot.AddressExpression); - Assert.Equal(content.Value, snapshot.Value); - Assert.Equal(content.VariableType, snapshot.VariableType); - Assert.Equal(state.CurrentAddress, snapshot.CurrentAddress); - Assert.Equal(state.IsActive, snapshot.IsActive); - Assert.Equal(state.ChildCount, snapshot.ChildCount); + Assert.Equal( + ["Content", "Id", "Index", "State"], + typeof(MemoryRecordSnapshot).GetProperties().Select(static property => property.Name) + .Order(StringComparer.Ordinal)); + } + + /// Carries the asynchronous activation facts of a copied record state. + [Fact] + public void StateSnapshotCarriesTheAsynchronousActivationFacts() + { + MemoryRecordStateSnapshot state = new(null, isActive: true, childCount: 1, isAsync: true, + isAsyncProcessing: true); + MemoryRecordStateSnapshot defaults = CreateState(); + + Assert.True(state.IsActive); + Assert.Equal(1, state.ChildCount); + Assert.True(state.IsAsync); + Assert.True(state.IsAsyncProcessing); + Assert.False(defaults.IsAsync); + Assert.False(defaults.IsAsyncProcessing); + } + + /// Carries the script and pointer-offset count of a copied record content. + [Fact] + public void ContentSnapshotCarriesTheScriptAndTheOffsetCount() + { + MemoryRecordContentSnapshot script = new("Infinite ammo", string.Empty, string.Empty, VariableType.Dword, + "[ENABLE]\n[DISABLE]", 0); + MemoryRecordContentSnapshot pointer = new("Health", "[game.exe+20]+8", "100", VariableType.Dword, + offsetCount: 2); + + Assert.Equal("[ENABLE]\n[DISABLE]", script.Script); + Assert.Null(pointer.Script); + Assert.Equal(2, pointer.OffsetCount); + Assert.Equal(0, CreateContent().OffsetCount); + } + + /// Rejects a negative pointer-offset count in a copied record content. + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void ContentSnapshotRejectsANegativeOffsetCount(int offsetCount) + { + ArgumentOutOfRangeException exception = Assert.Throws(() => + new MemoryRecordContentSnapshot("Health", "game.exe+20", "100", VariableType.Dword, null, offsetCount)); + + Assert.Equal("offsetCount", exception.ParamName); } /// Rejects a record index that cannot identify a valid address-list position. @@ -159,22 +216,19 @@ public void ContentSnapshotRejectsNullRequiredValues() Assert.Equal("value", value.ParamName); } - /// Normalizes default child arrays to empty both during construction and with-expression updates. + /// Normalizes default child arrays to empty, at construction and at the default value. [Fact] - public void HierarchySnapshotNormalizesDefaultChildrenDuringConstructionAndWithUpdate() + public void HierarchySnapshotNormalizesDefaultChildren() { MemoryRecordHierarchySnapshot hierarchy = new(CreateSnapshot( new MemoryRecordId(12), 0, CreateContent(), CreateState()), default); - MemoryRecordHierarchySnapshot updatedHierarchy = hierarchy with { Children = default }; MemoryRecordHierarchySnapshot uninitializedHierarchy = default; Assert.False(hierarchy.Children.IsDefault); Assert.Empty(hierarchy.Children); - Assert.False(updatedHierarchy.Children.IsDefault); - Assert.Empty(updatedHierarchy.Children); Assert.False(uninitializedHierarchy.Children.IsDefault); Assert.Empty(uninitializedHierarchy.Children); } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs deleted file mode 100644 index 3d21a56..0000000 --- a/tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CheatEngine.Client.Timers; - -namespace CheatEngine.Client.Abstractions.Tests.Timers; - -public sealed class TimerContractsTests -{ - [Fact] - public void RequestPreservesAStrictlyPositiveInterval() - { - TimerRequest request = new(TimeSpan.FromSeconds(1)); - - Assert.Equal(TimeSpan.FromSeconds(1), request.Interval); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void RequestRejectsANonPositiveInterval(int milliseconds) - { - Assert.Throws(() => new TimerRequest(TimeSpan.FromMilliseconds(milliseconds))); - } - - [Theory] - [InlineData(-1)] - [InlineData(-2)] - public void TickRejectsANegativeSequence(long sequence) - { - Assert.Throws(() => new TimerTick(sequence, DateTimeOffset.UtcNow)); - } - - [Fact] - public void TickPreservesTheInitialSequenceAndTimestamp() - { - DateTimeOffset occurredAt = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); - TimerTick tick = new(0, occurredAt); - - Assert.Equal(0, tick.Sequence); - Assert.Equal(occurredAt, tick.OccurredAt); - } -} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json index 6cf7ac7..baddace 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json @@ -2,17 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -24,6 +13,35 @@ "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, )", @@ -34,6 +52,12 @@ "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, )", @@ -55,12 +79,12 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", "dependencies": { - "System.IO.Hashing": "10.0.12" + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" } }, "Microsoft.DiaSymReader": { @@ -73,11 +97,6 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -113,11 +132,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -184,14 +198,20 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" } } } diff --git a/tests/CheatEngine.Client.AotProbe/AotProbeFluentCalls.cs b/tests/CheatEngine.Client.AotProbe/AotProbeFluentCalls.cs new file mode 100644 index 0000000..ebdab1e --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbeFluentCalls.cs @@ -0,0 +1,197 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Memory; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.AotProbe; + +/// +/// Calls every public member of CheatEngine.Client.Fluent under Native AOT, against in-process fakes of the memory +/// service and the pattern scanner, and checks what each call returns. +/// +/// +/// AotProbeCoverageTests (CheatEngine.Client.Repository.Tests) reads this file: every public Fluent type has an +/// Exercise<TypeName> method here that calls each public member of that type, and +/// calls every Exercise method. The test counts call sites, not overloads, so each overload keeps its own +/// call with an argument of that overload's type. Each call's effect is checked before a later call replaces it. +/// +internal static class AotProbeFluentCalls +{ + private const int Int32Value = 0x1234_5678; + private const long Int64Value = 0x0102_0304_0506_0708; + private const string Text = "probe"; + private const int Utf8Bytes = 16; + private const int Utf16Bytes = 32; + private const string Pattern = "48 8B ?? 89"; + + /// Runs every Fluent call and reports whether each one returned what the fakes hold. + /// when every call behaved as expected. + internal static bool Run() + { + AotProbeTargetMemory memory = new(); + AotProbePatternScanner scanner = new(); + return ExerciseCheatEngineMemoryFluentExtensions(memory) + && ExerciseMemoryAddressBuilder(memory) + && ExerciseMemoryPointerChainBuilder(memory) + && ExerciseMemoryPrimitiveBatchBuilder(memory) + && ExerciseCheatEngineAobFluentExtensions(scanner) + && ExerciseAobScanBuilder(scanner) + && ExerciseAobFirstMatchBuilder(scanner) + && ExerciseAobManyMatchBuilder(scanner) + && ExerciseAobSingleMatchBuilder(scanner); + } + + private static Address Slot(int offset) + { + return AotProbeTargetMemory.BaseAddress + offset; + } + + private static bool ExerciseCheatEngineMemoryFluentExtensions(AotProbeTargetMemory memory) + { + MemoryAddressBuilder address = memory.At(Slot(0x00)); + MemoryPrimitiveBatchBuilder batch = memory.Batch(); + return address.Address == Slot(0x00) + && batch.TryRead([Slot(0x00)], out ImmutableArray values, out _) + && values.Length == 1; + } + + private static bool ExerciseMemoryAddressBuilder(AotProbeTargetMemory memory) + { + MemoryAddressBuilder int32 = memory.At(Slot(0x00)); + int32.Write(Int32Value); + MemoryAddressBuilder int64 = memory.At(Slot(0x08)); + bool primitives = int32.Address == Slot(0x00) + && int32.Read() == Int32Value + && int64.TryWrite(Int64Value, out _) + && int64.TryRead(out long readInt64, out _) + && readInt64 == Int64Value; + + MemoryAddressBuilder bytes = memory.At(Slot(0x10)); + bytes.WriteBytes([1, 2, 3, 4]); + MemoryAddressBuilder moreBytes = memory.At(Slot(0x18)); + bool byteRuns = bytes.ReadBytes(4) is [1, 2, 3, 4] + && moreBytes.TryWriteBytes([5, 6], out _) + && moreBytes.TryReadBytes(2, out ImmutableArray readBytes, out _) + && readBytes is [5, 6]; + + MemoryAddressBuilder utf8 = memory.At(Slot(0x20)); + utf8.WriteString(Text, Utf8Bytes, MemoryStringEncoding.Utf8); + MemoryAddressBuilder utf16 = memory.At(Slot(0x30)); + bool strings = utf8.ReadString(Utf8Bytes, MemoryStringEncoding.Utf8) == Text + && utf16.TryWriteString(Text, Utf16Bytes, MemoryStringEncoding.Utf16, out _) + && utf16.TryReadString(Utf16Bytes, MemoryStringEncoding.Utf16, out string? readUtf16, out _) + && readUtf16 == Text; + + AotProbePointCodec codec = new(); + AotProbePoint point = new(-7, 11); + AotProbePoint moved = new(point.X, 12); + MemoryAddressBuilder custom = memory.At(Slot(0x50)); + custom.WriteWith(point, codec); + MemoryAddressBuilder moreCustom = memory.At(Slot(0x58)); + bool codecs = custom.ReadWith(codec) == point + && moreCustom.TryWriteWith(moved, codec, out _) + && moreCustom.TryReadWith(codec, out AotProbePoint readPoint, out _) + && readPoint == moved; + + MemoryPointerChainBuilder chain = memory.At(Slot(0x60)).Follow([0x08]); + return primitives && byteRuns && strings && codecs && chain.Request.BaseAddress == Slot(0x60); + } + + private static bool ExerciseMemoryPointerChainBuilder(AotProbeTargetMemory memory) + { + memory.At(Slot(0x60)).Write(Slot(0x70).Value); + MemoryPointerChainBuilder chain = memory.At(Slot(0x60)).Follow([0x08]); + return chain.Request.Offsets is [0x08] + && chain.Resolve() == Slot(0x78) + && chain.TryResolve(out Address resolved, out _) + && resolved == Slot(0x78); + } + + private static bool ExerciseMemoryPrimitiveBatchBuilder(AotProbeTargetMemory memory) + { + MemoryPrimitiveBatchBuilder batch = memory.Batch(); + batch.Write([new MemoryAddressValue(Slot(0x80), 1), new MemoryAddressValue(Slot(0x84), 2)]); + return batch.Read([Slot(0x80), Slot(0x84)]) is [1, 2] + && batch.TryWrite([new MemoryAddressValue(Slot(0x88), 3)], out _) + && batch.TryRead([Slot(0x84), Slot(0x88)], out ImmutableArray values, out _) + && values is [2, 3]; + } + + private static bool ExerciseCheatEngineAobFluentExtensions(AotProbePatternScanner scanner) + { + AobPattern pattern = new(Pattern); + return scanner.Aob(Pattern).Pattern == pattern && scanner.Aob(pattern).Pattern == pattern; + } + + private static bool ExerciseAobScanBuilder(AotProbePatternScanner scanner) + { + ModuleName named = new("game.exe"); + ModuleName module = new("other.dll"); + AobScanRange range = new(new Address(0x1000), new Address(0x9000)); + + // Each builder replaces a setting of the previous one, so each step is checked on its own builder. + AobScanBuilder byName = scanner.Aob(Pattern).InModule(named.Value); + AobScanBuilder byModule = byName.InModule(module).InRange(range.Start, range.End); + AobScanBuilder executable = byModule.Executable(); + AobScanBuilder writable = executable.Writable(); + AobScanBuilder anyProtection = writable.WithProtection(default); + AobScanBuilder lastDigits = anyProtection.LastDigits("0f"); + AobScanBuilder alignedTo = lastDigits.AlignedTo(4); + AobScanBuilder scan = alignedTo.WithAlignment(ScanAlignment.AlignedTo(8)); + bool eachStep = byName.Module == named + && byModule.Module == module + && byModule.Range == range + && executable.Protection == new ScanProtectionFilter(ScanProtectionRequirement.Required, + ScanProtectionRequirement.Excluded, ScanProtectionRequirement.Excluded) + && writable.Protection == new ScanProtectionFilter(ScanProtectionRequirement.Unspecified, + ScanProtectionRequirement.Excluded, ScanProtectionRequirement.Required) + && anyProtection.Protection.IsUnspecified + && lastDigits.Alignment is { Mode: ScanAlignmentMode.LastDigits, Digits: "0F" } + && alignedTo.Alignment is { Mode: ScanAlignmentMode.AlignedTo, Divisor: 4 } + && scan.Alignment is { Mode: ScanAlignmentMode.AlignedTo, Divisor: 8 }; + AobFirstMatchBuilder first = scan.FirstOrNone(); + bool firstCopiesOne = first.Execute() == AotProbePatternScanner.Match + && scanner.LastRequest.MaximumResults == 1; + AobManyMatchBuilder many = scan.Take(3); + bool manyCopiesThree = many.Execute().Matches.Length == 1 && scanner.LastRequest.MaximumResults == 3; + AobSingleMatchBuilder single = scan.RequireSingle(); + bool singleCopiesTwo = single.Execute() == AotProbePatternScanner.Match + && scanner.LastRequest.MaximumResults == 2; + return eachStep + && scan.Pattern == new AobPattern(Pattern) + && scan.Module == module + && scan.Range == range + && scan.Protection.IsUnspecified + && firstCopiesOne + && manyCopiesThree + && singleCopiesTwo; + } + + private static bool ExerciseAobFirstMatchBuilder(AotProbePatternScanner scanner) + { + AobFirstMatchBuilder first = scanner.Aob("90").FirstOrNone(); + return first.Execute() == AotProbePatternScanner.Match + && first.TryExecute(out Address? address, out _) + && address == AotProbePatternScanner.Match; + } + + private static bool ExerciseAobManyMatchBuilder(AotProbePatternScanner scanner) + { + AobManyMatchBuilder many = scanner.Aob("90").Take(2); + AobScanResult executed = many.Execute(); + return executed.Matches.Length == 1 + && executed.Matches[0] == AotProbePatternScanner.Match + && many.TryExecute(out AobScanResult result, out _) + && result is { IsTruncated: false, Matches.Length: 1 }; + } + + private static bool ExerciseAobSingleMatchBuilder(AotProbePatternScanner scanner) + { + AobSingleMatchBuilder single = scanner.Aob("90").RequireSingle(); + return single.Execute() == AotProbePatternScanner.Match + && single.TryExecute(out Address address, out _) + && address == AotProbePatternScanner.Match; + } +} diff --git a/tests/CheatEngine.Client.AotProbe/AotProbePatternScanner.cs b/tests/CheatEngine.Client.AotProbe/AotProbePatternScanner.cs new file mode 100644 index 0000000..6c2fa65 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbePatternScanner.cs @@ -0,0 +1,47 @@ +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.AotProbe; + +/// +/// A pattern scanner behind the Fluent AOB builders that reports one match, read exhaustively, for every request, +/// so that each terminal runs under Native AOT without a Cheat Engine host. +/// +/// The Fluent terminals read only; the other members throw. +internal sealed class AotProbePatternScanner : IPatternScanner +{ + /// Gets the one address every scan reports. + internal static Address Match => new(0x7FF6_0000_1000); + + /// Gets the request of the last scan. + internal AobScanRequest LastRequest + { + get; + private set; + } + + /// + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("The Fluent AOB terminals read the detailed scan outcome only."); + } + + /// + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) + { + throw new NotSupportedException("The Fluent AOB terminals read the detailed scan outcome only."); + } + + /// + public PatternScanOutcome ScanDetailed(AobScanRequest request, CancellationToken cancellationToken = default) + { + LastRequest = request; + PatternScanMetrics metrics = new(PatternScanScope.GlobalHostScan, hostResultCount: 1, examinedCount: 1, + filteredOutCount: 0, materializedCount: 1, belowStartSkippedCount: 0, atOrAfterStopSkippedCount: 0, + unreadHostRowCount: 0, inBoundsCountIsExact: true, TimeSpan.Zero, TimeSpan.Zero); + return new PatternScanOutcome(new AobScanResult([Match], isTruncated: false), null, metrics, + PatternScanHostOutcomeKind.Matches, PatternScanRouteReason.UnscopedRequest, targetIdentityVerified: false); + } +} diff --git a/tests/CheatEngine.Client.AotProbe/AotProbePointCodec.cs b/tests/CheatEngine.Client.AotProbe/AotProbePointCodec.cs new file mode 100644 index 0000000..af0445d --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbePointCodec.cs @@ -0,0 +1,45 @@ +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.AotProbe; + +/// Encodes an as two little-endian 32-bit integers. +internal sealed class AotProbePointCodec : IMemoryCodec +{ + /// + public bool TryRead(IMemoryReadContext context, Address address, [MaybeNullWhen(false)] out AotProbePoint value, + out CheatEngineFailure failure) + { + ArgumentNullException.ThrowIfNull(context); + Span buffer = stackalloc byte[2 * sizeof(int)]; + if (!context.TryReadBytes(address, buffer, out failure)) + { + value = default; + return false; + } + + value = new AotProbePoint(BinaryPrimitives.ReadInt32LittleEndian(buffer), + BinaryPrimitives.ReadInt32LittleEndian(buffer[sizeof(int)..])); + return true; + } + + /// + public bool TryWrite(IMemoryWriteContext context, Address address, in AotProbePoint value, + out CheatEngineFailure failure) + { + ArgumentNullException.ThrowIfNull(context); + Span buffer = stackalloc byte[2 * sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(buffer, value.X); + BinaryPrimitives.WriteInt32LittleEndian(buffer[sizeof(int)..], value.Y); + return context.TryWriteBytes(address, buffer, out failure); + } +} + +/// A custom value type that only a codec can read or write. +/// The first coordinate. +/// The second coordinate. +internal readonly record struct AotProbePoint(int X, int Y); diff --git a/tests/CheatEngine.Client.AotProbe/AotProbeTargetMemory.cs b/tests/CheatEngine.Client.AotProbe/AotProbeTargetMemory.cs new file mode 100644 index 0000000..23cfad8 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbeTargetMemory.cs @@ -0,0 +1,381 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.AotProbe; + +/// +/// A 256-byte in-process target behind the Fluent memory builders, so that each builder route runs under Native AOT +/// without a Cheat Engine host. +/// +/// +/// Strings are NUL-terminated within their explicit maximum length, in bytes. A pointer chain reads a 64-bit +/// pointer at each step and adds the step's offset. The detailed outcome members are not used by Fluent and throw. +/// +internal sealed class AotProbeTargetMemory : IMemoryClient, IMemoryReadContext, IMemoryWriteContext +{ + private const string Operation = "AotProbe.TargetMemory"; + private const int Size = 256; + private readonly byte[] _bytes = new byte[Size]; + + /// Gets the first address of the target. + internal static Address BaseAddress => new(0x1000); + + /// + public PointerSize Bitness => PointerSize.Bit64; + + /// + public PointerSize ConfiguredPointerSize => PointerSize.Bit64; + + /// + public int? ConfiguredPointerSizeBytes => PointerSize.Bit64.Bytes; + + /// + public bool? ConfiguredPointerSizeDiffersFromBitness => false; + + /// + public bool TryReadBytes(Address address, Span destination, out CheatEngineFailure failure) + { + if (!TryLocate(address, destination.Length, out int offset, out failure)) + { + return false; + } + + _bytes.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + /// + public bool TryWriteBytes(Address address, ReadOnlySpan source, out CheatEngineFailure failure) + { + if (!TryLocate(address, source.Length, out int offset, out failure)) + { + return false; + } + + source.CopyTo(_bytes.AsSpan(offset)); + return true; + } + + /// + public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged + { + Span buffer = stackalloc byte[Unsafe.SizeOf()]; + if (!TryReadBytes(address, buffer, out failure)) + { + value = default; + return false; + } + + value = MemoryMarshal.Read(buffer); + return true; + } + + /// + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + where T : unmanaged + { + if (!TryReadPrimitive(address, out T value, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + + return value; + } + + /// + public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + where T : unmanaged + { + Span buffer = stackalloc byte[Unsafe.SizeOf()]; + MemoryMarshal.Write(buffer, in value); + return TryWriteBytes(address, buffer, out failure); + } + + /// + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + where T : unmanaged + { + if (!TryWritePrimitive(address, value, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + /// + public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, out ImmutableArray values, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged + { + ImmutableArray.Builder read = ImmutableArray.CreateBuilder(request.Addresses.Length); + foreach (Address address in request.Addresses) + { + if (!TryReadPrimitive(address, out T value, out failure, cancellationToken)) + { + values = default; + return false; + } + + read.Add(value); + } + + values = read.MoveToImmutable(); + failure = default; + return true; + } + + /// + public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, + CancellationToken cancellationToken = default) + where T : unmanaged + { + if (!TryReadPrimitiveBatch(request, out ImmutableArray values, out CheatEngineFailure failure, + cancellationToken)) + { + failure.Throw(cancellationToken); + } + + return values; + } + + /// + public MemoryPrimitiveBatchReadOutcome ReadPrimitiveBatchDetailed( + MemoryPrimitiveBatchReadRequest request, CancellationToken cancellationToken = default) + where T : unmanaged + { + throw new NotSupportedException("The Fluent batch builder never reads a detailed outcome."); + } + + /// + public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + where T : unmanaged + { + foreach (MemoryAddressValue write in request.Values) + { + if (!TryWritePrimitive(write.Address, write.Value, out failure, cancellationToken)) + { + return false; + } + } + + failure = default; + return true; + } + + /// + public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken = default) + where T : unmanaged + { + if (!TryWritePrimitiveBatch(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + /// + public MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchDetailed( + MemoryPrimitiveBatchWriteRequest request, CancellationToken cancellationToken = default) + where T : unmanaged + { + throw new NotSupportedException("The Fluent batch builder never reads a detailed outcome."); + } + + /// + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[request.Length]; + if (!TryReadBytes(request.Address, buffer, out failure)) + { + bytes = default; + return false; + } + + bytes = [.. buffer]; + return true; + } + + /// + public ImmutableArray ReadBytes(MemoryBytesReadRequest request, CancellationToken cancellationToken = default) + { + if (!TryReadBytes(request, out ImmutableArray bytes, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + + return bytes; + } + + /// + public MemoryBytesReadOutcome ReadBytesDetailed(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("The Fluent address builder never reads a detailed outcome."); + } + + /// + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryWriteBytes(request.Address, request.Bytes.AsSpan(), out failure); + } + + /// + public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWriteBytes(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + /// + public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[request.MaximumLength]; + if (!TryReadBytes(request.Address, buffer, out failure)) + { + value = null; + return false; + } + + value = request.Encoding == MemoryStringEncoding.Utf16 + ? new string(BeforeNul(MemoryMarshal.Cast(buffer.AsSpan(0, buffer.Length & ~1)))) + : Encoding.UTF8.GetString(BeforeNul(buffer)); + return true; + } + + /// + public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default) + { + if (!TryReadString(request, out string? value, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + + return value; + } + + /// + public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + Encoding encoding = request.Encoding == MemoryStringEncoding.Utf16 ? Encoding.Unicode : Encoding.UTF8; + int terminator = request.Encoding == MemoryStringEncoding.Utf16 ? sizeof(char) : sizeof(byte); + byte[] encoded = encoding.GetBytes(request.Value); + byte[] buffer = new byte[Math.Min(encoded.Length + terminator, request.MaximumLength)]; + encoded.AsSpan(0, Math.Min(encoded.Length, buffer.Length)).CopyTo(buffer); + return TryWriteBytes(request.Address, buffer, out failure); + } + + /// + public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWriteString(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + /// + public bool TryResolvePointerChain(PointerChainRequest request, out Address address, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + Address current = request.BaseAddress; + foreach (long offset in request.Offsets) + { + if (!TryReadPrimitive(current, out ulong pointer, out failure, cancellationToken)) + { + address = default; + return false; + } + + current = new Address(pointer) + offset; + } + + address = current; + failure = default; + return true; + } + + /// + public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) + { + if (!TryResolvePointerChain(request, out Address address, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + + return address; + } + + /// + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return request.Codec.TryRead(this, request.Address, out value, out failure); + } + + /// + public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default) + { + if (!TryRead(request, out T? value, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + + return value; + } + + /// + public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return request.Codec.TryWrite(this, request.Address, request.Value, out failure); + } + + /// + public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWrite(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + private static ReadOnlySpan BeforeNul(ReadOnlySpan text) + where T : unmanaged, IEquatable + { + int end = text.IndexOf(default(T)); + return end < 0 ? text : text[..end]; + } + + private static bool TryLocate(Address address, int length, out int offset, out CheatEngineFailure failure) + { + ulong start = address.Value - BaseAddress.Value; + if (address < BaseAddress || start > Size || (ulong) length > Size - start) + { + offset = 0; + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, Operation, + "The range lies outside the probe's target memory.", null, CheatEngineHostEffect.NotStarted); + return false; + } + + offset = (int) start; + failure = default; + return true; + } +} diff --git a/tests/CheatEngine.Client.AotProbe/Program.cs b/tests/CheatEngine.Client.AotProbe/Program.cs index 9a2b350..6a61d00 100644 --- a/tests/CheatEngine.Client.AotProbe/Program.cs +++ b/tests/CheatEngine.Client.AotProbe/Program.cs @@ -2,31 +2,27 @@ using CheatEngine.Client.Allocations; using CheatEngine.Client.AotProbe; using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; -using CheatEngine.Client.Events; using CheatEngine.Client.Extensions.DependencyInjection; -using CheatEngine.Client.Hashing; using CheatEngine.Client.Hosting; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; -using CheatEngine.Client.Timers; using Microsoft.Extensions.DependencyInjection; ServiceCollection services = new(); +#pragma warning disable CECLIENT5004 // The probe composes the experimental Auto Assembler opt-in under NativeAOT. services.AddCheatEngineClient() .AddLuaModule() - .EnableUnsafeLuaExecution(); + .EnableUnsafeLuaExecution() + .EnableAutoAssemblerPatches(); +#pragma warning restore CECLIENT5004 using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); _ = typeof(ICheatEngineClient); @@ -38,35 +34,22 @@ _ = typeof(PointerChainRequest); _ = typeof(CheatEngineLuaModuleAttribute); _ = typeof(CheatEngineLuaOperationAttribute); -_ = typeof(IDescribedLuaModule); +_ = typeof(ILuaModule); _ = typeof(ILuaResultMapper<,>); _ = typeof(LuaModuleDescriptor); _ = typeof(AotProbeLuaModule); _ = AotProbeMapperInvocation.Map(42); -_ = typeof(IAllocationClient); -_ = typeof(ITargetMemoryLease); +#pragma warning disable CECLIENT5003 // The experimental instruction surface stays reachable under NativeAOT. _ = typeof(IAssemblyClient); -_ = typeof(IAutoAssemblerPatchLease); -_ = typeof(IRemoteExecutionClient); -_ = typeof(IDebuggerClient); -_ = typeof(IBreakpointLease); -_ = typeof(IHotkeyClient); -_ = typeof(IHotkeyLease); -_ = typeof(ITimerClient); -_ = typeof(ITimerLease); -_ = typeof(ISpeedClient); -_ = typeof(IHashingClient); -_ = typeof(IDbvmClient); -_ = typeof(IDbvmWatchLease); -_ = typeof(IEventStreamLease<>); -_ = typeof(EventStreamOptions); -_ = typeof(BreakpointRequest); -_ = typeof(HotkeyRegistration); -_ = typeof(TimerRequest); -_ = typeof(RemoteCallRequest); -_ = typeof(MemoryHashRequest); -_ = typeof(DbvmWatchRequest); _ = typeof(AssemblyInstructionRequest); +_ = typeof(AssemblyInstructionSnapshot); +_ = typeof(InstructionEncodingPreference); +#pragma warning restore CECLIENT5003 +#pragma warning disable CECLIENT5004 // The experimental Auto Assembler surface stays reachable under NativeAOT. +_ = typeof(IAutoAssemblerClient); +_ = typeof(IAutoAssemblerPatchLease); +_ = typeof(AutoAssemblerCheckResult); +#pragma warning restore CECLIENT5004 _ = new AobPattern("90"); const string evidenceReason = "Activation evidence is current."; @@ -75,10 +58,40 @@ satisfiedGate, satisfiedGate, satisfiedGate, satisfiedGate, satisfiedGate, satisfiedGate); ClientCapabilityAvailability capabilityAvailability = new(ClientCapabilityId.ProcessSelection, evidence); if (evidence.EffectiveReasonCode != ClientCapabilityEvidenceReasonCode.Lifetime || - evidence.EffectiveReason != evidenceReason || - capabilityAvailability.State != ClientCapabilityAvailabilityState.Available || - !capabilityAvailability.IsAvailable || !capabilityAvailability.IsKnown || - capabilityAvailability.Reason != evidenceReason) + evidence.EffectiveReason != evidenceReason || + capabilityAvailability.State != ClientCapabilityAvailabilityState.Available || + !capabilityAvailability.IsAvailable || !capabilityAvailability.IsKnown || + capabilityAvailability.Reason != evidenceReason) +{ + return 1; +} + +#pragma warning disable CECLIENT5001 // The probe keeps the experimental value scans in the NativeAOT graph. +_ = typeof(IValueScanner); +_ = typeof(IValueScanSession); +ValueScanFirstRequest firstScan = ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(100)) + .WithAlignment(ScanAlignment.AlignedTo(4)); +if (firstScan.ValueType != ValueScanValueType.Integer32 || firstScan.Alignment.Divisor != 4 || + ValueScanValue.FromBytes([0x90, 0x0F]).Text != "90 0F" || + new ValueScanPage(0, 2, [new ValueScanMatch(default, "100")]).NextStartIndex != 1) +{ + return 1; +} +#pragma warning restore CECLIENT5001 + +#pragma warning disable CECLIENT5002 // The probe keeps the experimental target allocations in the NativeAOT graph. +_ = typeof(IAllocationClient); +_ = typeof(ITargetMemoryLease); +AllocationRequest allocation = new(4096, AllocationProtection.ExecuteReadWrite); +if (allocation.Size != 4096 || allocation.Protection != AllocationProtection.ExecuteReadWrite || + allocation.PreferredAddress is not null) +{ + return 1; +} +#pragma warning restore CECLIENT5002 + +// Every public Fluent member runs under Native AOT against in-process fakes (AotProbeCoverageTests). +if (!AotProbeFluentCalls.Run()) { return 1; } diff --git a/tests/CheatEngine.Client.AotProbe/README.md b/tests/CheatEngine.Client.AotProbe/README.md index ccae8e6..f47bc61 100644 --- a/tests/CheatEngine.Client.AotProbe/README.md +++ b/tests/CheatEngine.Client.AotProbe/README.md @@ -16,6 +16,16 @@ Publishing this probe turns AOT warnings into a delivery gate. It detects reflec metadata usage, or transitive AOT regressions while keeping the result separate from Cheat Engine's managed plugin loader requirements. +Running the published executable also executes code, not only compiles it. `AotProbeFluentCalls` calls every public +member of `CheatEngine.Client.Fluent` against in-process fakes: `AotProbeTargetMemory`, a 256-byte target behind +`IMemoryClient` (primitives, bytes, UTF-8 and UTF-16 strings, a custom codec, pointer chains and batches), and +`AotProbePatternScanner`, which reports one match for every AOB request. Each call's result is checked, a builder +step's before a later step replaces it, and the probe exits with 1 when one differs. `AotProbeCoverageTests` +(`CheatEngine.Client.Repository.Tests`) reads the Fluent PublicAPI baselines and fails when a public Fluent member has +fewer call sites in the probe, comments excluded, than public signatures, so a new builder member joins the probe with +its API entry. The count is textual and cannot tell which overload a call binds to: the probe gives each overload its +own call, with an argument of that overload's type. + It does **not** claim that Cheat Engine can load a Native AOT plugin DLL. Plugins generated by `ceplugin` remain managed and must include the SDK bootstrap and bridge assets. @@ -24,8 +34,10 @@ and must include the SDK bootstrap and bridge assets. From the repository root: ```powershell -dotnet publish .\tests\CheatEngine.Client.AotProbe\CheatEngine.Client.AotProbe.csproj --configuration Release +dotnet publish .\tests\CheatEngine.Client.AotProbe\CheatEngine.Client.AotProbe.csproj --configuration Release ` + --output .\artifacts\aot-probe +.\artifacts\aot-probe\CheatEngine.Client.AotProbe.exe ``` -The successful output is a Native AOT executable under the repository artifacts path for `win-x64`; it is not intended -to be copied into a Cheat Engine plugin directory. +The successful output is a Native AOT executable that exits with 0; it is not intended to be copied into a Cheat Engine +plugin directory. diff --git a/tests/CheatEngine.Client.AotProbe/packages.lock.json b/tests/CheatEngine.Client.AotProbe/packages.lock.json index 4300e86..c523072 100644 --- a/tests/CheatEngine.Client.AotProbe/packages.lock.json +++ b/tests/CheatEngine.Client.AotProbe/packages.lock.json @@ -14,24 +14,11 @@ "resolved": "10.0.12", "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, - "Microsoft.SourceLink.GitHub": { + "MinVer": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" }, "Microsoft.Extensions.Configuration": { "type": "Transitive", @@ -56,41 +43,31 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "cheatengine.client": { "type": "Project", "dependencies": { - "CheatEngine.Client.Fluent": "[0.1.0, )", - "CheatEngine.Client.Hosting": "[0.1.0, )" + "CheatEngine.Client.Fluent": "[1.0.0, )", + "CheatEngine.Client.Hosting": "[1.0.0, )" } }, "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -101,23 +78,23 @@ "cheatengine.client.fluent": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )" + "CheatEngine.Client.Abstractions": "[1.0.0, )" } }, "cheatengine.client.hosting": { "type": "Project", "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive", @@ -214,4 +191,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Benchmarks/AobRouteComparisonBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/AobRouteComparisonBenchmarks.cs new file mode 100644 index 0000000..0f14afe --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/AobRouteComparisonBenchmarks.cs @@ -0,0 +1,178 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; + +using BenchmarkDotNet.Attributes; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Benchmarks; + +/// +/// Compares the Client cost of the two routes a module request can take (CRIT-16): the global route, which copies, +/// parses and post-filters every row of a global result list, and the bounded route, which receives only the +/// in-module addresses. Both run the production scanner over a fake port. +/// +/// +/// +/// Cheat Engine's scan time is deliberately excluded (audit ch.24 "separate the costs"): the bounded route's real +/// advantage is the Cheat Engine work it avoids, which only a live host can measure, and this suite publishes no +/// host number. What it shows is the managed side of the same answer: the global route pays for every row +/// Cheat Engine returned, the bounded route only for the rows inside the module. +/// +/// +/// As in , 1 % of the global rows fall inside the module. +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory("AobRouteComparison", "Informational")] +public class AobRouteComparisonBenchmarks +{ + private const ulong ModuleBase = 0x1000_0000; + private const ulong ModuleSize = 0x1000_0000; + + private AobScanRequest _request; + private PatternScanner _scanner = null!; + + /// Gets or sets the number of rows the global route's result list holds. + [Params(10_000, 1_000_000)] + public int HostRowCount + { + get; + set; + } + + /// Gets or sets whether the target is qualified, so the module request takes the bounded route. + [Params(false, true)] + public bool BoundedRoute + { + get; + set; + } + + /// Builds the global rows, their in-module subset and a scanner whose dispatcher runs inline. + [GlobalSetup] + public void Setup() + { + string[] rows = new string[HostRowCount]; + List
inModule = []; + for (int index = 0; index < rows.Length; index++) + { + ulong offset = (ulong) index * 16; + if (index % 100 == 0) + { + ulong address = ModuleBase + offset; + rows[index] = address.ToString("X8", CultureInfo.InvariantCulture); + inModule.Add(new Address(address)); + continue; + } + + rows[index] = (0x7FFC_0000_0000UL + offset).ToString("X", CultureInfo.InvariantCulture); + } + + ModuleInfo module = new("game.exe", new Address(ModuleBase), new MemorySize(ModuleSize), true, "game.exe"); + _scanner = new PatternScanner(InlineCoreHost.CreateDispatcher(InlineCoreHost.CreateLifetime()), + new RoutePort(rows, [.. inModule], module, BoundedRoute)); + _request = new AobScanRequest(new AobPattern("48 8B ?? ?? ?? 89"), inModule.Count, new ModuleName("game.exe")); + } + + /// Runs one module request through the production scanner on the configured route. + /// The number of copied addresses, so the JIT cannot discard the work. + [Benchmark] + public int ScanModule() + { + if (!_scanner.TryScan(_request, out AobScanResult result, out _)) + { + throw new InvalidOperationException("The benchmark scan unexpectedly failed."); + } + + return result.Matches.Length; + } + + /// A port that answers both routes from memory; the selection is qualified only for the bounded route. + private sealed class RoutePort(string[] rows, Address[] inModule, ModuleInfo module, bool qualified) : IAobScanPort + { + private readonly TargetSelectionFacts _selection = qualified + ? new TargetSelectionFacts(TargetSelectionObservationStatus.CurrentTargetQualified, TargetBackend.LocalProcess, + 42, Incarnation(42, 1_000)) + : default; + + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches) + { + matches = new RowList(rows); + return new AobHostOutcome(AobScanOutcomeKind.Matches, LuaStatus.Ok, rows.Length, default, default); + } + + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + int written = Math.Min(inModule.Length, destination.Length); + inModule.AsSpan(0, written).CopyTo(destination); + return new AobBoundedHostResult + { + Kind = written > 0 ? AobBoundedScanOutcomeKind.Matches : AobBoundedScanOutcomeKind.NoMatches, + CreationStatus = MemoryScanCreationStatus.Success, + LuaStatus = LuaStatus.Ok, + HostResultCount = (ulong) inModule.Length, + Written = written, + RowsRead = (ulong) written, + UnreadHostRows = (ulong) (inModule.Length - written), + IsMaterializationLimitReached = written == destination.Length && inModule.Length > written, + HostScanElapsed = TimeSpan.FromTicks(1), + FoundListRelease = TargetReleaseStatus.Released, + MemScanRelease = TargetReleaseStatus.Released, + ReleaseTermination = MemoryScanTerminationStatus.NotRequired + }; + } + + public TargetSelectionFacts ObserveSelection() + { + return _selection; + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + destination[0] = module; + written = 1; + return InspectionStatus.Success; + } + + /// The SDK's incarnation constructor is internal; a benchmark double builds one like the SDK would. + private static TargetProcessIncarnation Incarnation(int processId, long startedAtUtcTicks) + { + ConstructorInfo constructor = typeof(TargetProcessIncarnation).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, [typeof(int), typeof(long)]) + ?? throw new InvalidOperationException( + "CheatEngine.SDK no longer declares TargetProcessIncarnation(int, long)."); + return (TargetProcessIncarnation) constructor.Invoke([processId, startedAtUtcTicks]); + } + } + + private sealed class RowList(string[] rows) : IAobMatchList + { + public bool TryGetCount(out int count) + { + count = rows.Length; + return true; + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + value = (uint) index < (uint) rows.Length ? rows[index] : null; + return value is not null; + } + + public TargetReleaseStatus Release() + { + return TargetReleaseStatus.Released; + } + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs b/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs index f1e5ac5..befdee1 100644 --- a/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs +++ b/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs @@ -41,7 +41,24 @@ public static void WriteTo(string artifactsDirectory) "di-client-registration", 1, false, - "Informational allocation and elapsed-time baseline for DI composition.") + "Informational allocation and elapsed-time baseline for DI composition."), + new BenchmarkWorkloadDescriptor( + "aob-materialization", + 1, + false, + "Client copy/parse/filter cost over a fake port; excludes CE scan time."), + new BenchmarkWorkloadDescriptor( + "aob-route-comparison", + 1, + false, + "Client cost of a module request on the global post-filter route and on the bounded route over a " + + "fake port; excludes CE scan time."), + new BenchmarkWorkloadDescriptor( + "memory-batch", + 1, + false, + "Client admission, dispatch and outcome cost of a primitive batch over a fake port; excludes CE " + + "memory access time.") ]); string content = JsonSerializer.Serialize(descriptor, BenchmarkSuiteJsonContext.Default.BenchmarkSuiteDescriptor); diff --git a/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs index 62b8cb5..9159b3a 100644 --- a/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs +++ b/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs @@ -1,10 +1,10 @@ +using System.Reflection; + using BenchmarkDotNet.Attributes; using CheatEngine.Client.Memory; using CheatEngine.SDK.Engine.Values; -using MemoryFluent = CheatEngine.Client.Memory.Memory; - namespace CheatEngine.Client.Benchmarks; /// Measures allocation-free construction of immutable fluent address builders. @@ -13,20 +13,25 @@ namespace CheatEngine.Client.Benchmarks; public class FluentBuilderBenchmarks { private Address _address; + private IMemoryClient _memory = null!; - /// Initializes a non-constant input so the JIT cannot fold the fluent operation. + /// + /// Initializes a non-constant input so the JIT cannot fold the fluent operation, and the memory service the + /// builders are bound to; building never calls that service. + /// [GlobalSetup] public void Setup() { _address = 0x401000UL; + _memory = DispatchProxy.Create(); } - /// Measures one unbound pure-builder creation without a Client-to-SDK transition. + /// Measures one bound pure-builder creation without a Client-to-SDK transition. [Benchmark] [BenchmarkCategory("PureBuilder", "AllocationGate")] - public MemoryAddressBuilder CreateUnboundBuilder() + public MemoryAddressBuilder CreateBoundBuilder() { - return MemoryFluent.At(_address); + return _memory.At(_address); } /// Measures the builder construction and its copied address projection. @@ -34,6 +39,16 @@ public MemoryAddressBuilder CreateUnboundBuilder() [BenchmarkCategory("PureBuilder", "AllocationGate")] public Address CreateAndProjectAddress() { - return MemoryFluent.At(_address).Address; + return _memory.At(_address).Address; + } + + /// The memory service a measured builder is bound to; a benchmark never runs a terminal. + public class UnusedMemoryProxy : DispatchProxy + { + /// + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + throw new NotSupportedException("Building a Fluent memory operation must not call the memory service."); + } } } diff --git a/tests/CheatEngine.Client.Benchmarks/InlineCoreHost.cs b/tests/CheatEngine.Client.Benchmarks/InlineCoreHost.cs new file mode 100644 index 0000000..6926890 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/InlineCoreHost.cs @@ -0,0 +1,62 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Benchmarks; + +/// +/// A Core activation that is always current and a dispatcher that runs callbacks inline, so a benchmark measures the +/// Client's own work over a fake port and never a Cheat Engine thread hop. +/// +internal static class InlineCoreHost +{ + /// Creates an always-current Core lifetime. + internal static CoreLifetime CreateLifetime() + { + return new CoreLifetime(new AlwaysCurrentLifetimeContext()); + } + + /// Creates the production dispatcher over an inline main-thread invoker. + internal static SdkMainThreadDispatcher CreateDispatcher(CoreLifetime lifetime) + { + return new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()); + } + + private sealed class AlwaysCurrentLifetimeContext : ICoreLifetimeContext + { + public long Epoch => 1; + + public bool IsCurrent => true; + + public bool IsMainThread => true; + + public CancellationToken Stopping => CancellationToken.None; + } + + private sealed class InlineMainThreadInvoker : IMainThreadInvoker + { + public Exception? Invoke(Action callback) + { + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + public MainThreadInvocationResult Invoke(Func callback) + { + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/MemoryBatchBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/MemoryBatchBenchmarks.cs new file mode 100644 index 0000000..1afb807 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/MemoryBatchBenchmarks.cs @@ -0,0 +1,162 @@ +using BenchmarkDotNet.Attributes; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Benchmarks; + +/// +/// Measures only the Client cost of a primitive batch (audit A24-04): the budget admission, one dispatch, the +/// per-element port calls and the outcome materialization, over a fake port that answers from memory. Cheat Engine's +/// own cost per read or write is deliberately excluded (audit ch.24 "separate the costs"): it is a live-host +/// measurement, and this suite publishes no host number. +/// +/// +/// The Address batch also pays its one target observation before the first element, as a real batch does; the +/// integer batches never observe the target. +/// +[MemoryDiagnoser] +[BenchmarkCategory("MemoryBatch", "Informational")] +public class MemoryBatchBenchmarks +{ + private MemoryClient _client = null!; + private MemoryPrimitiveBatchReadRequest
_pointerReads; + private MemoryPrimitiveBatchReadRequest _reads; + private MemoryPrimitiveBatchWriteRequest _writes; + + /// Gets or sets the number of operations in each batch, up to the hard per-batch limit. + [Params(1, 64, MemoryBatchLimits.MaximumOperationCount)] + public int OperationCount + { + get; + set; + } + + /// Builds the batch requests and a memory client whose dispatcher runs inline over the fake port. + [GlobalSetup] + public void Setup() + { + Address[] addresses = new Address[OperationCount]; + MemoryAddressValue[] values = new MemoryAddressValue[OperationCount]; + for (int index = 0; index < OperationCount; index++) + { + addresses[index] = new Address(0x0040_0000UL + ((ulong) index * sizeof(int))); + values[index] = new MemoryAddressValue(addresses[index], index); + } + + _reads = new MemoryPrimitiveBatchReadRequest(addresses); + _writes = new MemoryPrimitiveBatchWriteRequest(values); + _pointerReads = new MemoryPrimitiveBatchReadRequest
(addresses); + CoreLifetime lifetime = InlineCoreHost.CreateLifetime(); + _client = new MemoryClient(InlineCoreHost.CreateDispatcher(lifetime), lifetime, new InMemoryPort(), + new MemoryResourceLimits()); + } + + /// Reads one homogeneous 32-bit integer batch and copies its immutable values. + /// The completed count, so the JIT cannot discard the work. + [Benchmark] + public int ReadInt32Batch() + { + MemoryPrimitiveBatchReadOutcome outcome = _client.ReadPrimitiveBatchDetailed(_reads); + return outcome.IsSuccess + ? outcome.CompletedCount + : throw new InvalidOperationException("The benchmark batch read unexpectedly failed."); + } + + /// Writes one homogeneous 32-bit integer batch and reports its effect state. + /// The completed count, so the JIT cannot discard the work. + [Benchmark] + public int WriteInt32Batch() + { + MemoryPrimitiveBatchWriteOutcome outcome = _client.WritePrimitiveBatchDetailed(_writes); + return outcome.IsSuccess + ? outcome.CompletedCount + : throw new InvalidOperationException("The benchmark batch write unexpectedly failed."); + } + + /// Reads one Address batch, which observes the target width once before its first element. + /// The completed count, so the JIT cannot discard the work. + [Benchmark] + public int ReadAddressBatch() + { + MemoryPrimitiveBatchReadOutcome
outcome = _client.ReadPrimitiveBatchDetailed(_pointerReads); + return outcome.IsSuccess + ? outcome.CompletedCount + : throw new InvalidOperationException("The benchmark pointer batch read unexpectedly failed."); + } + + /// An x64 local target whose every read returns zero and every write succeeds, without CheatEngine.SDK. + private sealed class InMemoryPort : IMemoryCodecContextPort + { + private static readonly TargetArchitectureObservation Target = new(new TargetProcessId(42), + TargetBackend.LocalProcess, PointerSize.Bit64, true, false, false, 0, sizeof(ulong)); + + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) + { + observation = new CurrentProcessObservation(Target.ProcessId, Target.Bitness); + return ProcessOperationStatus.Success; + } + + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) + { + observation = Target; + return ProcessOperationStatus.Success; + } + + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize) + { + rawBytes = sizeof(ulong); + pointerSize = PointerSize.Bit64; + return ProcessOperationStatus.Success; + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + destination.Clear(); + written = destination.Length; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) + { + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryReadPrimitive(Address address, out T value, out MemoryAccessFailure failure) + { + value = default!; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWritePrimitive(Address address, T value, out MemoryAccessFailure failure) + { + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, + out MemoryAccessFailure failure) + { + value = default; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWritePointer(Address address, Address value, PointerSize pointerSize, + out MemoryAccessFailure failure) + { + failure = MemoryAccessFailure.None; + return true; + } + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/PatternScannerMaterializationBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/PatternScannerMaterializationBenchmarks.cs new file mode 100644 index 0000000..15a8047 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/PatternScannerMaterializationBenchmarks.cs @@ -0,0 +1,136 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +using BenchmarkDotNet.Attributes; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Benchmarks; + +/// +/// Measures only the Client cost of an AOB scan: reading the count, copying, parsing, and post-filtering the result +/// list returned by a fake port. Cheat Engine's scan time is deliberately excluded (audit ch.24 "separate the costs"); +/// it is a C3 measurement, not something this benchmark can observe. +/// +/// +/// The fake list mixes the two address formats Cheat Engine returns (spike C3 D4.10): unpadded upper-case hexadecimal +/// on x64 targets and eight-digit zero-padded hexadecimal on x86 targets. With the module filter enabled, 1 % of the +/// addresses fall inside the module, which reproduces the "many global matches, few in the module" shape where the +/// post-filter copies little but the global scan still costs a full scan. +/// +[MemoryDiagnoser] +[BenchmarkCategory("AobMaterialization", "Informational")] +public class PatternScannerMaterializationBenchmarks +{ + private const ulong ModuleBase = 0x1000_0000; + private const ulong ModuleSize = 0x1000_0000; + + private AobScanRequest _request; + private PatternScanner _scanner = null!; + + /// Gets or sets the number of entries in the fake Cheat Engine result list. + [Params(1_000, 100_000, 1_000_000)] + public int HostMatchCount + { + get; + set; + } + + /// Gets or sets whether the request applies the managed module post-filter. + [Params(false, true)] + public bool ModuleFilter + { + get; + set; + } + + /// Builds the fake result list and a scanner whose dispatcher runs inline. + [GlobalSetup] + public void Setup() + { + string[] entries = new string[HostMatchCount]; + for (int index = 0; index < entries.Length; index++) + { + ulong offset = (ulong) index * 16; + entries[index] = (index % 100) switch + { + 0 => (ModuleBase + offset).ToString("X8", CultureInfo.InvariantCulture), + _ when index % 2 == 0 => (0x7FFC_0000_0000UL + offset).ToString("X", CultureInfo.InvariantCulture), + _ => (0x0040_0000UL + (offset % 0x0FC0_0000)).ToString("X8", CultureInfo.InvariantCulture) + }; + } + + ModuleInfo module = new("game.exe", new Address(ModuleBase), new MemorySize(ModuleSize), true, "game.exe"); + _scanner = new PatternScanner(InlineCoreHost.CreateDispatcher(InlineCoreHost.CreateLifetime()), + new InMemoryAobScanPort(entries, module)); + _request = new AobScanRequest(new AobPattern("48 8B ?? ?? ?? 89"), HostMatchCount, + ModuleFilter ? new ModuleName("game.exe") : null); + } + + /// Copies, parses, and filters the complete fake result list through the production scanner. + /// The number of copied addresses, so the JIT cannot discard the work. + [Benchmark] + public int MaterializeResultList() + { + if (!_scanner.TryScan(_request, out AobScanResult result, out _)) + { + throw new InvalidOperationException("The benchmark scan unexpectedly failed."); + } + + return result.Matches.Length; + } + + private sealed class InMemoryAobScanPort(string[] entries, ModuleInfo module) : IAobScanPort + { + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches) + { + matches = new InMemoryAobMatchList(entries); + return new AobHostOutcome(AobScanOutcomeKind.Matches, LuaStatus.Ok, entries.Length, default, default); + } + + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + throw new NotSupportedException("This benchmark measures the global route only."); + } + + /// An unobserved selection: a module request takes the global route with its managed post-filter. + public TargetSelectionFacts ObserveSelection() + { + return default; + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + destination[0] = module; + written = 1; + return InspectionStatus.Success; + } + } + + private sealed class InMemoryAobMatchList(string[] entries) : IAobMatchList + { + public bool TryGetCount(out int count) + { + count = entries.Length; + return true; + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + value = (uint) index < (uint) entries.Length ? entries[index] : null; + return value is not null; + } + + public TargetReleaseStatus Release() + { + return TargetReleaseStatus.Released; + } + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/Program.cs b/tests/CheatEngine.Client.Benchmarks/Program.cs index 388f80d..428f7e6 100644 --- a/tests/CheatEngine.Client.Benchmarks/Program.cs +++ b/tests/CheatEngine.Client.Benchmarks/Program.cs @@ -65,14 +65,14 @@ private static bool TryReadArtifactsPath( { string argument = args[index]!; if (argument.Equals("-a", StringComparison.OrdinalIgnoreCase) || - argument.Equals("--artifacts", StringComparison.OrdinalIgnoreCase)) + argument.Equals("--artifacts", StringComparison.OrdinalIgnoreCase)) { artifactsPath = index + 1 < args.Count ? args[++index] : null; return artifactsPath is not null; } return TryReadAssignedArtifactsPath(argument, "-a=", out artifactsPath) || - TryReadAssignedArtifactsPath(argument, "--artifacts=", out artifactsPath); + TryReadAssignedArtifactsPath(argument, "--artifacts=", out artifactsPath); } private static bool TryReadAssignedArtifactsPath( diff --git a/tests/CheatEngine.Client.Benchmarks/packages.lock.json b/tests/CheatEngine.Client.Benchmarks/packages.lock.json index d97abc4..f1741b6 100644 --- a/tests/CheatEngine.Client.Benchmarks/packages.lock.json +++ b/tests/CheatEngine.Client.Benchmarks/packages.lock.json @@ -20,16 +20,11 @@ "System.Management": "9.0.5" } }, - "Microsoft.SourceLink.GitHub": { + "MinVer": { "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" }, "BenchmarkDotNet.Annotations": { "type": "Transitive", @@ -51,14 +46,6 @@ "resolved": "1.21.0", "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", "resolved": "4.14.0", @@ -120,11 +107,6 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Perfolizer": { "type": "Transitive", "resolved": "0.6.1", @@ -143,11 +125,6 @@ "resolved": "9.0.5", "contentHash": "cuzLM2MWutf9ZBEMPYYfd0DXwYdvntp7VCT6a/wvbKCa2ZuvGmW74xi+YBa2mrfEieAXqM4TNKlMmSnfAfpUoQ==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Management": { "type": "Transitive", "resolved": "9.0.5", @@ -164,21 +141,21 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -189,14 +166,14 @@ "cheatengine.client.fluent": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )" + "CheatEngine.Client.Abstractions": "[1.0.0, )" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.CodeAnalysis.Analyzers": { "type": "CentralTransitive", diff --git a/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj b/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj index 7d2fc0f..cc6c51f 100644 --- a/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj +++ b/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj @@ -1,7 +1,26 @@ + + + $(NoWarn);CECLIENT5001;CECLIENT5002 + + + + + + + + + + + + diff --git a/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs index 90f8bb2..dee800d 100644 --- a/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs @@ -1,23 +1,18 @@ +#pragma warning disable CECLIENT5003 // The composition tests cover the experimental instruction client property. + using System.Reflection; using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; using CheatEngine.Client.Core.Tests.TestSupport; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; namespace CheatEngine.Client.Core.Tests.Composition; @@ -37,13 +32,6 @@ public void ExposesTheRuntimeAndDomainServicesFromItsActivationComposition() ILuaClient lua = CreateProxy(); IAllocationClient allocations = CreateProxy(); IAssemblyClient assembly = CreateProxy(); - IRemoteExecutionClient remoteExecution = CreateProxy(); - IDebuggerClient debugger = CreateProxy(); - IHotkeyClient hotkeys = CreateProxy(); - ITimerClient timers = CreateProxy(); - ISpeedClient speed = CreateProxy(); - IHashingClient hashing = CreateProxy(); - IDbvmClient dbvm = CreateProxy(); CheatEngineClient client = new( InertCoreLifetime.Create(), @@ -57,33 +45,19 @@ public void ExposesTheRuntimeAndDomainServicesFromItsActivationComposition() tables, lua, allocations, - assembly, - remoteExecution, - debugger, - hotkeys, - timers, - speed, - hashing, - dbvm)); + assembly)); Assert.Same(runtime, client.Runtime); Assert.Same(dispatcher, client.Dispatcher); Assert.Same(processes, client.Processes); Assert.Same(memory, client.Memory); Assert.Same(patterns, client.Patterns); - Assert.Same(scans, client.Scans); + Assert.Same(scans, client.ValueScans); Assert.Same(inspection, client.Inspection); Assert.Same(tables, client.Tables); Assert.Same(lua, client.Lua); Assert.Same(allocations, client.Allocations); Assert.Same(assembly, client.Assembly); - Assert.Same(remoteExecution, client.RemoteExecution); - Assert.Same(debugger, client.Debugger); - Assert.Same(hotkeys, client.Hotkeys); - Assert.Same(timers, client.Timers); - Assert.Same(speed, client.Speed); - Assert.Same(hashing, client.Hashing); - Assert.Same(dbvm, client.Dbvm); } private static T CreateProxy() diff --git a/tests/CheatEngine.Client.Core.Tests/Composition/FluentAobTerminalCompositionTests.cs b/tests/CheatEngine.Client.Core.Tests/Composition/FluentAobTerminalCompositionTests.cs new file mode 100644 index 0000000..0e032fe --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Composition/FluentAobTerminalCompositionTests.cs @@ -0,0 +1,218 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Composition; + +/// +/// Runs the Fluent AOB terminals, compiled in from libs/CheatEngine.Client.Fluent/Scanning, against Core's real +/// and the AOB port doubles, route by route: FirstOrNone, RequireSingle +/// and Take judge the shapes Core publishes, not a scanner double's model of them. +/// +/// +/// The host rows are space-separated hexadecimal addresses, and a row list is the global +/// route's nil result. The request is a 4-byte pattern scoped to the module game.exe +/// [0x4000, 0x4100), except on the unscoped route: 40FC is the module's last whole match, 40FD +/// to 40FF straddle its end and 4100 starts after it, so the Client's scope rule drops them on every +/// route. +/// +public sealed class FluentAobTerminalCompositionTests +{ + /// A factual zero on each route: every row Cheat Engine returned was read and none lay inside the request. + [Theory] + [InlineData(PatternScanScope.HostBoundedRange, "")] + [InlineData(PatternScanScope.HostBoundedRange, "40FD")] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter, "3000 40FD 5000")] + [InlineData(PatternScanScope.GlobalHostScan, "")] + public void EveryTerminalReportsTheFactualZeroOfEveryRoute(PatternScanScope route, string rows) + { + Address? first = new Address(1); + CheatEngineFailure firstFailure = default; + AobScanResult many = default; + + bool firstSucceeded = Run(route, rows, builder => builder.FirstOrNone().TryExecute(out first, + out firstFailure, TestContext.Current.CancellationToken)); + CheatEngineFailure single = RequireSingleFailure(route, rows); + bool manySucceeded = Run(route, rows, builder => builder.Take(2).TryExecute(out many, out _, + TestContext.Current.CancellationToken)); + + Assert.True(firstSucceeded, firstFailure.Message); + Assert.Null(first); + Assert.Equal((CheatEngineFailureKind.NotFound, "Patterns.Scan"), (single.Kind, single.Operation)); + Assert.True(manySucceeded); + Assert.Empty(many.Matches); + Assert.False(many.IsTruncated); + } + + /// + /// A zero that Core cannot prove is never , + /// or an empty result: the bounded route's full destination of rows outside the request (a row stays unread), + /// and the global routes' nil result. Core fails both itself, and every terminal returns that failure. + /// + [Theory] + [Trait("Qualification", "Q27")] + [InlineData(PatternScanScope.HostBoundedRange, "40FD 40FE 40FF 4100")] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter, null)] + [InlineData(PatternScanScope.GlobalHostScan, null)] + public void NoTerminalTurnsAnUnprovenZeroIntoAnAnswer(PatternScanScope route, string? rows) + { + CheatEngineFailure first = default; + CheatEngineFailure many = default; + + bool firstSucceeded = Run(route, rows, builder => builder.FirstOrNone().TryExecute(out _, out first, + TestContext.Current.CancellationToken)); + CheatEngineFailure single = RequireSingleFailure(route, rows); + bool manySucceeded = Run(route, rows, builder => builder.Take(2).TryExecute(out _, out many, + TestContext.Current.CancellationToken)); + + Assert.False(firstSucceeded); + Assert.False(manySucceeded); + Assert.All([first, single, many], static failure => + { + Assert.Equal((CheatEngineFailureKind.IndeterminateHostResult, "Patterns.Scan"), + (failure.Kind, failure.Operation)); + // The terminals name their own failures Patterns.Scan too: the message tells Core's failure from theirs. + Assert.NotEqual(AobTerminal.Unread("Patterns.Scan").Message, failure.Message); + }); + } + + /// One match inside the request, next to a row outside it on the scoped routes, is proven unique. + [Theory] + [InlineData(PatternScanScope.HostBoundedRange, "3000 4010")] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter, "3000 4010")] + [InlineData(PatternScanScope.GlobalHostScan, "4010")] + public void RequireSingleReturnsTheOnlyMatchOnEveryRoute(PatternScanScope route, string rows) + { + Address address = default; + CheatEngineFailure failure = default; + + bool succeeded = Run(route, rows, builder => builder.RequireSingle().TryExecute(out address, out failure, + TestContext.Current.CancellationToken)); + + Assert.True(succeeded, failure.Message); + Assert.Equal(new Address(0x4010), address); + } + + /// + /// A second copied match is ambiguous on every route, whether Core copied exactly two matches or cut the copy at + /// two because a third exists. + /// + [Theory] + [InlineData(PatternScanScope.HostBoundedRange, "4010 4020")] + [InlineData(PatternScanScope.HostBoundedRange, "4010 4020 4030")] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter, "4010 4020")] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter, "4010 4020 4030")] + [InlineData(PatternScanScope.GlobalHostScan, "4010 4020")] + [InlineData(PatternScanScope.GlobalHostScan, "4010 4020 4030")] + public void RequireSingleReportsASecondCopiedMatchAsAmbiguousOnEveryRoute(PatternScanScope route, string rows) + { + CheatEngineFailure failure = RequireSingleFailure(route, rows); + + Assert.Equal((CheatEngineFailureKind.AmbiguousMatch, "Patterns.Scan"), (failure.Kind, failure.Operation)); + } + + /// + /// The bounded route's destination for RequireSingle holds three rows: 40FC and two rows the Client + /// drops, with 4100 unread. Core publishes one match, truncated, without an exact count: whether a second + /// match exists is unknown, which is never reported as ambiguous. The global route reads every row and proves + /// 40FC unique. FirstOrNone and Take return 40FC on both routes. + /// + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(PatternScanScope.HostBoundedRange)] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter)] + public void AMatchNextToRowsStraddlingTheModuleEndIsNeverReportedAsAmbiguous(PatternScanScope route) + { + const string rows = "40FC 40FD 40FF 4100"; + Address? first = null; + Address single = default; + CheatEngineFailure singleFailure = default; + AobScanResult many = default; + + bool firstSucceeded = Run(route, rows, builder => builder.FirstOrNone().TryExecute(out first, out _, + TestContext.Current.CancellationToken)); + bool singleSucceeded = Run(route, rows, builder => builder.RequireSingle().TryExecute(out single, + out singleFailure, TestContext.Current.CancellationToken)); + bool manySucceeded = Run(route, rows, builder => builder.Take(2).TryExecute(out many, out _, + TestContext.Current.CancellationToken)); + + Assert.True(firstSucceeded); + Assert.Equal(new Address(0x40FC), first); + Assert.True(manySucceeded); + Assert.Equal([new Address(0x40FC)], many.Matches); + if (route == PatternScanScope.HostBoundedRange) + { + Assert.False(singleSucceeded); + Assert.Equal((CheatEngineFailureKind.IndeterminateHostResult, "Patterns.Scan"), + (singleFailure.Kind, singleFailure.Operation)); + Assert.Equal(CheatEngineHostEffect.Completed, singleFailure.HostEffect); + } + else + { + Assert.True(singleSucceeded, singleFailure.Message); + Assert.Equal(new Address(0x40FC), single); + } + } + + /// Runs one terminal on a fresh port and checks that the scan took the requested route. + private static bool Run(PatternScanScope route, string? rows, Func terminal) + { + FakeAobScanPort port = Port(route, rows); + PatternScanner scanner = new( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + AobScanBuilder builder = scanner.Aob("90 90 90 90"); + + bool succeeded = terminal(route == PatternScanScope.GlobalHostScan ? builder : builder.InModule("game.exe")); + + Assert.Equal(route == PatternScanScope.HostBoundedRange ? 1 : 0, port.BoundedCalls); + Assert.Equal(route == PatternScanScope.HostBoundedRange ? 0 : 1, port.ScanCalls); + return succeeded; + } + + private static CheatEngineFailure RequireSingleFailure(PatternScanScope route, string? rows) + { + CheatEngineFailure failure = default; + + Assert.False(Run(route, rows, builder => builder.RequireSingle().TryExecute(out _, out failure, + TestContext.Current.CancellationToken))); + + return failure; + } + + /// + /// Builds the port of one route: a qualified local target whose bounded found list holds the rows, a CEServer + /// target whose global list the Client filters, or an unscoped global scan. + /// + private static FakeAobScanPort Port(PatternScanScope route, string? rows) + { + string[]? items = rows?.Split(' ', StringSplitOptions.RemoveEmptyEntries); + RecordingAobMatchList? list = items is null ? null : new RecordingAobMatchList(items); + ModuleInfo module = new("game.exe", new Address(0x4000), new MemorySize(0x100), true, "game.exe"); + return route switch + { + PatternScanScope.HostBoundedRange => new FakeAobScanPort(list) + { + Modules = [module], + Selection = AobHosts.Local(), + BoundedRows = [.. (items ?? []).Select(static item => Address.Parse(item))] + }, + PatternScanScope.GlobalHostScanWithManagedFilter => new FakeAobScanPort(list) + { + Modules = [module], + Selection = AobHosts.Remote, + Outcome = list is null + ? AobHosts.Outcome(AobScanOutcomeKind.NoResult, AobHosts.Remote, AobHosts.Remote) + : null + }, + _ => new FakeAobScanPort(list) + { + Outcome = list is null ? AobHosts.Outcome(AobScanOutcomeKind.NoResult) : null + } + }; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs index 68fecff..3265365 100644 --- a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs @@ -64,7 +64,10 @@ public void TryInvokeContractMapsInfrastructureFailuresToStructuredClientFailure using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); InvalidOperationException expected = new("host queue unavailable"); - RecordingMainThreadInvoker invoker = new() { HostException = expected }; + RecordingMainThreadInvoker invoker = new() + { + HostException = expected + }; SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); bool callbackRan = false; @@ -111,7 +114,10 @@ public void TryInvokeContractRejectsCancelledWorkBeforeDispatchAdmission(Dispatc [InlineData(DispatchForm.StatefulFunction)] public void TryInvokeContractRejectsAnExpiredActivationBeforeDispatchAdmission(DispatchForm form) { - using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using ControlledCoreLifetimeContext context = new() + { + IsCurrent = false + }; using CoreLifetime lifetime = new(context); RecordingMainThreadInvoker invoker = new(); SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); @@ -132,7 +138,10 @@ public void TryInvokeContractRejectsAnExpiredActivationBeforeDispatchAdmission(D [InlineData(DispatchForm.StatefulFunction)] public void TryInvokeContractPrioritizesAnExpiredActivationOverPreAdmissionCancellation(DispatchForm form) { - using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using ControlledCoreLifetimeContext context = new() + { + IsCurrent = false + }; using CoreLifetime lifetime = new(context); RecordingMainThreadInvoker invoker = new(); SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); @@ -149,6 +158,62 @@ public void TryInvokeContractPrioritizesAnExpiredActivationOverPreAdmissionCance Assert.Equal(0, invoker.InvocationCount); } + [Theory] + [InlineData(DispatchForm.Action)] + [InlineData(DispatchForm.Function)] + [InlineData(DispatchForm.StatefulFunction)] + public void TryInvokeContractThrowsLifecycleExceptionsInsteadOfReturningFailure(DispatchForm form) + { + using ControlledCoreLifetimeContext expiredContext = new() + { + IsCurrent = false + }; + using CoreLifetime expired = new(expiredContext); + using ControlledCoreLifetimeContext stoppingContext = new(); + using CoreLifetime stopping = new(stoppingContext); + stoppingContext.Stop(); + RecordingMainThreadInvoker invoker = new(); + + Assert.Throws(() => TryInvoke(form, + new SdkMainThreadDispatcher(expired, invoker), static () => + { + }, TestContext.Current.CancellationToken)); + CheatEngineInvalidStateException stoppingException = Assert.Throws(() => + TryInvoke(form, new SdkMainThreadDispatcher(stopping, invoker), static () => + { + }, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidState, stoppingException.Failure.Kind); + Assert.Equal(0, invoker.InvocationCount); + } + + [Fact] + public void PreAdmissionCancellationReportsThatNoCheatEngineWorkStarted() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new RecordingMainThreadInvoker()); + + bool succeeded = dispatcher.TryInvoke(static () => + { + }, out CheatEngineFailure failure, new CancellationToken(true)); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + } + + [Fact] + public void TryInvokeRejectsANullCallbackWithArgumentNullException() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new RecordingMainThreadInvoker()); + + Assert.Throws(() => + dispatcher.TryInvoke(null!, out _, TestContext.Current.CancellationToken)); + } + [Fact] public void ConstructorRejectsMissingLifecycleOrMainThreadInvoker() { @@ -174,41 +239,41 @@ private static DispatchInvocation TryInvoke( switch (form) { case DispatchForm.Action: - { - bool succeeded = dispatcher.TryInvoke(callback, out CheatEngineFailure failure, cancellationToken); - return new DispatchInvocation(succeeded, null, failure); - } + { + bool succeeded = dispatcher.TryInvoke(callback, out CheatEngineFailure failure, cancellationToken); + return new DispatchInvocation(succeeded, null, failure); + } case DispatchForm.Function: - { - bool succeeded = dispatcher.TryInvoke( - () => - { - callback(); - return 42; - }, - out int result, - out CheatEngineFailure failure, - cancellationToken); - return new DispatchInvocation(succeeded, result, failure); - } + { + bool succeeded = dispatcher.TryInvoke( + () => + { + callback(); + return 42; + }, + out int result, + out CheatEngineFailure failure, + cancellationToken); + return new DispatchInvocation(succeeded, result, failure); + } case DispatchForm.StatefulFunction: - { + { #pragma warning disable CA1859 // This helper intentionally exercises the internal stateful dispatch contract. - IStatefulCheatEngineDispatcher statefulDispatcher = dispatcher; + IStatefulCheatEngineDispatcher statefulDispatcher = dispatcher; #pragma warning restore CA1859 - CallbackState state = new(callback, 42); - bool succeeded = statefulDispatcher.TryInvoke( - state, - static current => - { - current.Callback(); - return current.Result; - }, - out int result, - out CheatEngineFailure failure, - cancellationToken); - return new DispatchInvocation(succeeded, result, failure); - } + CallbackState state = new(callback, 42); + bool succeeded = statefulDispatcher.TryInvoke( + state, + static current => + { + current.Callback(); + return current.Result; + }, + out int result, + out CheatEngineFailure failure, + cancellationToken); + return new DispatchInvocation(succeeded, result, failure); + } default: throw new ArgumentOutOfRangeException(nameof(form), form, null); } diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs index e5cee25..6e6e1da 100644 --- a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs @@ -30,11 +30,13 @@ public void InvokeConvertsARejectedDispatchToThePublicFailureException() using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - CheatEngineOperationException exception = Assert.Throws(() => + CheatEngineOperationCanceledException exception = Assert.Throws(() => dispatcher.Invoke(static () => { }, cancellation.Token)); + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Equal(CheatEngineHostEffect.NotStarted, exception.Failure.HostEffect); Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); Assert.Equal("Dispatcher.Invoke", exception.Failure.Operation); } @@ -42,7 +44,10 @@ public void InvokeConvertsARejectedDispatchToThePublicFailureException() [Fact] public void MainThreadPropertyRemainsFalseOutsideTheActualSdkMainThread() { - using ControlledCoreLifetimeContext context = new() { IsMainThread = true }; + using ControlledCoreLifetimeContext context = new() + { + IsMainThread = true + }; using CoreLifetime lifetime = new(context); SdkMainThreadDispatcher dispatcher = new(lifetime); diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/Allocations/AllocationClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/Allocations/AllocationClientTests.cs new file mode 100644 index 0000000..0108bae --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/Allocations/AllocationClientTests.cs @@ -0,0 +1,559 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.Allocations; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains.Allocations; + +/// +/// The allocation lifecycle against a scripted CheatEngine.SDK allocator (plan L16): a published lease and its one +/// release, a target change or a reused PID that the SDK refuses without freeing anything in the new target (Q30), a +/// runtime change, an unconfirmed or unavailable release, the compensation of an allocation that got no owner, +/// cancellation before and after Cheat Engine allocated, a process selected in Cheat Engine's own window that keeps the +/// allocations made in it, and a Dispose that never throws. +/// +public sealed class AllocationClientTests : IDisposable +{ + private readonly AllocationClient _client; + private readonly ControlledCoreLifetimeContext _context = new(); + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly CoreLifetime _lifetime; + private readonly FakeAllocationPort _port = new(); + private readonly ProcessClient _processes; + private readonly FakeSelectedTarget _target = new(); + + public AllocationClientTests() + { + _lifetime = new CoreLifetime(_context); + _dispatcher = new SdkMainThreadDispatcher(_lifetime, new InlineMainThreadInvoker()); + _processes = FakeSelectedTarget.CreateProcessClient(_dispatcher, _target); + _client = new AllocationClient(_dispatcher, _processes, _port); + } + + private FakeAllocatedRegion Region => _port.Region; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + public void Dispose() + { + _context.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AnAllocationIsPublishedAsALeaseAndReleasedOnce() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(4096), Token); + + Assert.Equal(FakeAllocationPort.AllocatedAddress, lease.Address); + Assert.Equal(4096, lease.Size); + Assert.Equal(AllocationProtection.ReadWrite, lease.Protection); + Assert.Equal(_lifetime.TargetSelection.Epoch, lease.SelectionEpoch); + Assert.False(lease.IsReleased); + Assert.False(lease.RequiresManualRecovery); + Assert.Null(lease.LastReleaseOutcome); + + LeaseReleaseOutcome released = lease.Release(); + LeaseReleaseOutcome again = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), released); + Assert.Equal(released, again); + Assert.True(lease.IsReleased); + Assert.False(lease.RequiresManualRecovery); + Assert.Equal(released, lease.LastReleaseOutcome); + Assert.Equal(1, Region.ReleaseCalls); + Assert.Equal(1, Region.Deallocations); + // The copied facts stay readable after the release. + Assert.Equal(FakeAllocationPort.AllocatedAddress, lease.Address); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void EveryRequestPassesAnExplicitProtectionAndItsPreferredAddress() + { + Address preferred = new(0x1_4000_0000); + + ITargetMemoryLease readWrite = _client.Allocate(new AllocationRequest(16), Token); + TargetAllocationRequest first = _port.LastRequest.GetValueOrDefault(); + ITargetMemoryLease executable = _client.Allocate( + new AllocationRequest(32, AllocationProtection.ExecuteReadWrite, preferred), Token); + TargetAllocationRequest second = _port.LastRequest.GetValueOrDefault(); + + Assert.Equal(16, first.Size.Value); + Assert.Equal(MemoryProtection.ReadWrite, first.Protection); + Assert.Null(first.PreferredBaseAddress); + Assert.Equal(32, second.Size.Value); + Assert.Equal(MemoryProtection.ExecuteReadWrite, second.Protection); + Assert.Equal(preferred, second.PreferredBaseAddress); + Assert.Equal(AllocationProtection.ReadWrite, readWrite.Protection); + Assert.Equal(AllocationProtection.ExecuteReadWrite, executable.Protection); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AnAllocationThatIsNeverReleasedIsReleasedBeforeTheActivationEnds() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + _lifetime.DrainOwnedResourcesForDisable(); + } + + Assert.True(lease.IsReleased); + Assert.Equal(1, Region.Deallocations); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + lease.LastReleaseOutcome); + } + + [Theory] + [Trait("Qualification", "Q30.a")] + [Trait("Qualification", "Q30.b")] + [InlineData(TargetReleaseStatus.RefusedTargetChanged)] + [InlineData(TargetReleaseStatus.RefusedProcessReused)] + public void ATargetChangeReleasesTheLeaseAndTheSdkRefusalFreesNothingInTheNewTarget(TargetReleaseStatus refusal) + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + Region.ReleaseStatus = refusal; + + _ = _lifetime.TargetSelection.Advance("Processes.Attach"); + int releaseCalls = Region.ReleaseCalls; + LeaseReleaseOutcome again = lease.Release(); + CheatEngineOperationException reported = DrainExpectingOneReport(); + + Assert.True(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + lease.LastReleaseOutcome); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(1, releaseCalls); + Assert.Equal(0, Region.Deallocations); + Assert.Equal(1, _port.Allocations); + Assert.Equal(lease.LastReleaseOutcome, again); + Assert.Equal(1, Region.ReleaseCalls); + // The refused allocation stays in the deactivation report (Q43), under the kind of the refusal. + Assert.Equal(TargetMemoryLease.ReleaseOperation, reported.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.TargetChanged, reported.Failure.Kind); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void ARuntimeChangeRefusesTheReleaseWhichRequiresManualRecovery() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + Region.ReleaseStatus = TargetReleaseStatus.RefusedRuntimeChanged; + + LeaseReleaseOutcome released = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + released); + Assert.True(lease.IsReleased); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(0, Region.Deallocations); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AnUnconfirmedReleaseRequiresManualRecoveryAndIsNeverRetried() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + Region.ReleaseStatus = TargetReleaseStatus.UnconfirmedAfterInvocation; + + LeaseReleaseOutcome released = lease.Release(); + LeaseReleaseOutcome again = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + released); + Assert.True(lease.IsReleased); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(released, again); + Assert.Equal(1, Region.ReleaseCalls); + Assert.Equal(1, Region.Deallocations); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void AReleaseThatCannotBeginStaysRetryableAndIsReportedAtDeactivation() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + // CheatEngine.SDK consumes the owner when the runtime detached before deAlloc could begin. + Region.ReleaseStatus = TargetReleaseStatus.NotInvoked; + + LeaseReleaseOutcome released = lease.Release(); + bool releasedAfterFirstAttempt = lease.IsReleased; + CheatEngineOperationException reported = DrainExpectingOneReport(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + released); + Assert.False(releasedAfterFirstAttempt); + Assert.False(lease.RequiresManualRecovery); + // The deactivation cleanup retried through the consumed owner, which reports the same status without a Cheat Engine + // call, and the lease stays in the report. + Assert.True(Region.ReleaseCalls > 1); + Assert.Equal(0, Region.Deallocations); + Assert.Equal(TargetMemoryLease.ReleaseOperation, reported.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, reported.Failure.Kind); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void AReleaseWhileThePluginIsStoppingRunsInTheDeactivationCleanup() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + + _context.Stop(); + LeaseReleaseOutcome refused = lease.Release(); + int callsWhileStopping = Region.ReleaseCalls; + using (_lifetime.EnterCleanupScope()) + { + _lifetime.DrainOwnedResourcesForDisable(); + } + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + refused); + Assert.Equal(0, callsWhileStopping); + Assert.True(lease.IsReleased); + Assert.Equal(1, Region.Deallocations); + } + + [Fact] + public void DisposeNeverThrowsWhenTheReleaseFaults() + { + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + Region.ReleaseFault = new InvalidOperationException("deAlloc raised"); + + lease.Dispose(); + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + lease.LastReleaseOutcome); + Assert.Equal(1, Region.ReleaseCalls); + } + + [Theory] + [Trait("Qualification", "Q30.a")] + [InlineData(TargetMemoryOperationOutcomeKind.ExpectedFailure, EngineEffectState.NotApplied, + CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.NotApplied)] + [InlineData(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, EngineEffectState.NotStarted, + CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.NotStarted)] + [InlineData(TargetMemoryOperationOutcomeKind.TargetIdentityMismatch, EngineEffectState.NotStarted, + CheatEngineFailureKind.TargetChanged, CheatEngineHostEffect.NotStarted)] + [InlineData(TargetMemoryOperationOutcomeKind.GlobalUnavailable, EngineEffectState.NotStarted, + CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted)] + [InlineData(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, EngineEffectState.Unknown, + CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Unknown)] + [InlineData(TargetMemoryOperationOutcomeKind.MarshallingFailure, EngineEffectState.Unknown, + CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Unknown)] + public void ARefusedAllocationPublishesNoLease(TargetMemoryOperationOutcomeKind outcome, EngineEffectState effect, + CheatEngineFailureKind kind, CheatEngineHostEffect hostEffect) + { + _port.Refuse(outcome, effect); + + bool allocated = _client.TryAllocate(new AllocationRequest(64), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, Token); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(kind, failure.Kind); + Assert.Equal(hostEffect, failure.HostEffect); + Assert.Equal(AllocationClient.AllocateOperation, failure.Operation); + Assert.Equal(1, _port.Allocations); + Assert.Equal(0, Region.ReleaseCalls); + Assert.Throws(() => _client.Allocate(new AllocationRequest(64), Token)); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AnAllocationThatGotNoOwnerAndWasReleasedReportsNothingRemains() + { + _port.Refuse(TargetMemoryOperationOutcomeKind.Succeeded, EngineEffectState.Applied, + FakeAllocationPort.AllocatedAddress, TargetReleaseStatus.Released); + + bool allocated = _client.TryAllocate(new AllocationRequest(16), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, Token); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + } + + [Theory] + [Trait("Qualification", "Q30.a")] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, CheatEngineFailureKind.IndeterminateHostResult)] + [InlineData(TargetReleaseStatus.RefusedRuntimeChanged, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(TargetReleaseStatus.RefusedIdentityUnavailable, CheatEngineFailureKind.TargetIdentityUnavailable)] + public void AnUnconfirmedCompensationCarriesTheAddressForManualRecovery(TargetReleaseStatus compensation, + CheatEngineFailureKind kind) + { + _port.Refuse(TargetMemoryOperationOutcomeKind.Succeeded, EngineEffectState.Applied, + FakeAllocationPort.AllocatedAddress, compensation); + + bool allocated = _client.TryAllocate(new AllocationRequest(16), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, Token); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(kind, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Contains("16 bytes at 0x7FF000001000", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void CancellationBeforeTheAllocationReachesNoAllocator() + { + bool allocated = _client.TryAllocate(new AllocationRequest(64), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, new CancellationToken(true)); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, _port.Allocations); + Assert.Throws(() => + _client.Allocate(new AllocationRequest(64), new CancellationToken(true))); + } + + [Fact] + public void CancellationObservedAfterTheAllocationReleasesItAndPublishesNothing() + { + using CancellationTokenSource cancellation = new(); + _port.DuringAllocate = cancellation.Cancel; + + bool allocated = _client.TryAllocate(new AllocationRequest(64), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, cancellation.Token); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(1, Region.Deallocations); + } + + [Fact] + public void ALateCancellationWhoseReleaseIsRefusedCarriesTheAddress() + { + using CancellationTokenSource cancellation = new(); + _port.DuringAllocate = cancellation.Cancel; + Region.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + + bool allocated = _client.TryAllocate(new AllocationRequest(64), out _, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(allocated); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Contains("64 bytes at 0x7FF000001000", failure.Message, StringComparison.Ordinal); + Assert.Equal(0, Region.Deallocations); + } + + [Fact] + public void AnAllocatorFaultIsTranslatedWithAnUnknownEffect() + { + EngineLuaException fault = new("TargetMemoryAllocate", LuaStatus.RuntimeError, "allocateMemory raised"); + _port.Fault = fault; + + bool allocated = _client.TryAllocate(new AllocationRequest(64), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, Token); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Same(fault, failure.Exception); + } + + /// + /// The default request is a programming error: both forms throw, as its constructor does, before dispatch. + /// + [Fact] + public void TheDefaultRequestThrowsBeforeDispatch() + { + ArgumentOutOfRangeException thrown = + Assert.Throws(() => _client.TryAllocate(default, out _, out _, Token)); + + Assert.Equal("request", thrown.ParamName); + Assert.Throws(() => _client.Allocate(default, Token)); + Assert.Equal(0, _port.Allocations); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void ATargetChangeObservedDuringTheAllocationKeysTheLeaseToTheProcessItWasMadeIn() + { + _ = _processes.GetCurrentProcess(Token); + Region.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + // The SDK bound the allocation to process 42; Cheat Engine then selects process 43, which the Client observes + // before the lease is registered. + _port.DuringAllocate = () => + { + _target.Select(FakeSelectedTarget.OtherProcessIncarnation); + _ = _processes.GetCurrentProcess(Token); + }; + + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + bool releasedWhenPublished = lease.IsReleased; + ProcessSnapshot observed = _processes.GetCurrentProcess(Token); + + Assert.False(releasedWhenPublished); + // The next observation of process 43 releases the lease of process 42, and the SDK frees nothing in 43. + Assert.True(observed.SelectionEpoch > lease.SelectionEpoch); + Assert.True(lease.IsReleased); + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, lease.LastReleaseOutcome?.Kind); + Assert.Equal(1, Region.ReleaseCalls); + Assert.Equal(0, Region.Deallocations); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AnAllocationInAProcessSelectedInCheatEngineStaysWithThatProcess() + { + long observedEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + FakeAllocatedRegion first = Region; + first.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + ITargetMemoryLease inFirst = _client.Allocate(new AllocationRequest(64), Token); + // Cheat Engine's own window selects another process: no Client call observes it. + _target.Select(FakeSelectedTarget.OtherProcessIncarnation); + FakeAllocatedRegion second = new() + { + TargetIncarnation = FakeSelectedTarget.OtherProcessIncarnation + }; + _port.Region = second; + + ITargetMemoryLease inSecond = _client.Allocate(new AllocationRequest(64), Token); + long secondEpoch = inSecond.SelectionEpoch; + ProcessSnapshot observed = _processes.GetCurrentProcess(Token); + + // The first allocation's process is no longer selected: its lease was released when the second allocation was + // bound, and the SDK refused to free it in the new target. + Assert.Equal(observedEpoch, inFirst.SelectionEpoch); + Assert.True(inFirst.IsReleased); + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, inFirst.LastReleaseOutcome?.Kind); + Assert.Equal(0, first.Deallocations); + // The second allocation belongs to the selection the next observation finds, which releases nothing. + Assert.True(secondEpoch > observedEpoch); + Assert.Equal(secondEpoch, observed.SelectionEpoch); + Assert.Equal(new TargetProcessId(43), observed.Id); + Assert.False(inSecond.IsReleased); + Assert.Equal(0, second.ReleaseCalls); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AnAllocationInTheObservedProcessKeepsTheObservedSelection() + { + long observedEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + + ITargetMemoryLease lease = _client.Allocate(new AllocationRequest(64), Token); + ProcessSnapshot observed = _processes.GetCurrentProcess(Token); + + Assert.Equal(observedEpoch, lease.SelectionEpoch); + Assert.Equal(observedEpoch, observed.SelectionEpoch); + Assert.False(lease.IsReleased); + Assert.Equal(0, Region.ReleaseCalls); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void AnAllocationDuringTheDeactivationCleanupIsRefusedBeforeCheatEngineAllocates() + { + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + Assert.Throws(() => + _client.TryAllocate(new AllocationRequest(64), out _, out _, Token)); + } + + Assert.Equal(0, _port.Allocations); + } + + [Theory] + [Trait("Qualification", "Q43")] + [InlineData(TargetReleaseStatus.Released, "which was released at once")] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, "64 bytes at 0x7FF000001000")] + public void AnActivationStoppingDuringTheAllocationThrowsWhatTheReleaseLeft(TargetReleaseStatus release, + string expected) + { + _port.DuringAllocate = _context.Stop; + Region.ReleaseStatus = release; + + CheatEngineInvalidStateException stopping = Assert.Throws(() => + _client.TryAllocate(new AllocationRequest(64), out _, out _, Token)); + + Assert.Contains(expected, stopping.Message, StringComparison.Ordinal); + Assert.Equal(AllocationClient.AllocateOperation, stopping.Failure.Operation); + Assert.Equal(1, Region.ReleaseCalls); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void ARegistrationRefusedByAnotherSelectionIsAFailureThatCarriesTheAddress() + { + AllocationClient client = new(_dispatcher, new StaleSelectionBinder(), _port); + Region.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + + bool allocated = client.TryAllocate(new AllocationRequest(64), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, Token); + + Assert.False(allocated); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Contains("64 bytes at 0x7FF000001000", failure.Message, StringComparison.Ordinal); + Assert.IsType(failure.Exception); + Assert.Equal(1, Region.ReleaseCalls); + Assert.Equal(0, Region.Deallocations); + Assert.Throws(() => client.Allocate(new AllocationRequest(64), Token)); + } + + private CheatEngineOperationException DrainExpectingOneReport() + { + _context.Stop(); + AggregateException? report = null; + using (_lifetime.EnterCleanupScope()) + { + try + { + _lifetime.DrainOwnedResourcesForDisable(); + } + catch (CheatEngineOperationException single) + { + report = new AggregateException(single); + } + catch (AggregateException several) + { + report = several; + } + } + + Assert.NotNull(report); + return Assert.IsType(Assert.Single(report.InnerExceptions)); + } + + /// A binder that keys every owner to an epoch the selection already left. + private sealed class StaleSelectionBinder : ITargetSelectionBinder + { + public TargetSelectionBinding BindOwner(TargetProcessIncarnation incarnation, string operation) + { + return new TargetSelectionBinding(-1, null); + } + + public void ReportBinding(TargetSelectionBinding binding, string operation) + { + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/Allocations/AllocationMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/Allocations/AllocationMappingTests.cs new file mode 100644 index 0000000..52ceec5 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/Allocations/AllocationMappingTests.cs @@ -0,0 +1,219 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Core.Domains.Allocations; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains.Allocations; + +/// +/// Every allocation outcome of the consumed CheatEngine.SDK maps to its dedicated Client value, and a value the SDK +/// could add fails closed (plan L16, Q48). +/// +public sealed class AllocationMappingTests +{ + private const string Operation = "Allocations.Contract"; + + private static readonly Address Allocated = new(0x2_0000_0000); + + private static Dictionary ExpectedFailureKinds => new() + { + [TargetMemoryOperationOutcomeKind.Unspecified] = CheatEngineFailureKind.IndeterminateHostResult, + [TargetMemoryOperationOutcomeKind.Succeeded] = CheatEngineFailureKind.IndeterminateHostResult, + [TargetMemoryOperationOutcomeKind.ExpectedFailure] = CheatEngineFailureKind.OperationRejected, + [TargetMemoryOperationOutcomeKind.GlobalUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [TargetMemoryOperationOutcomeKind.CapabilityUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [TargetMemoryOperationOutcomeKind.ProtectedLuaFailure] = CheatEngineFailureKind.LuaError, + [TargetMemoryOperationOutcomeKind.BindingFailure] = CheatEngineFailureKind.BindingError, + [TargetMemoryOperationOutcomeKind.MarshallingFailure] = CheatEngineFailureKind.InvalidHostResult, + [TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable] = CheatEngineFailureKind.TargetIdentityUnavailable, + [TargetMemoryOperationOutcomeKind.TargetIdentityMismatch] = CheatEngineFailureKind.TargetChanged + }; + + private static Dictionary + ExpectedCompensations => new() + { + [TargetReleaseStatus.Released] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Completed), + [TargetReleaseStatus.RefusedNoTarget] = + (CheatEngineFailureKind.TargetNotAttached, CheatEngineHostEffect.CleanupUnconfirmed), + [TargetReleaseStatus.RefusedIdentityUnavailable] = + (CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.CleanupUnconfirmed), + [TargetReleaseStatus.RefusedTargetChanged] = + (CheatEngineFailureKind.TargetChanged, CheatEngineHostEffect.CleanupUnconfirmed), + [TargetReleaseStatus.RefusedProcessReused] = + (CheatEngineFailureKind.TargetChanged, CheatEngineHostEffect.CleanupUnconfirmed), + [TargetReleaseStatus.RefusedRuntimeChanged] = + (CheatEngineFailureKind.RuntimeChanged, CheatEngineHostEffect.CleanupUnconfirmed), + [TargetReleaseStatus.UnconfirmedAfterInvocation] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed), + [TargetReleaseStatus.NotInvoked] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed), + // No release attempted: nothing establishes that the allocation is gone. + [TargetReleaseStatus.Unspecified] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed) + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryAllocationCategoryMapsToItsFailureKind() + { + MappingTotality.AssertTotal( + static kind => ExpectedFailureKinds.TryGetValue(kind, out CheatEngineFailureKind expected) && + AllocationMapping.ToFailureKind(kind) == expected, + static kind => AllocationMapping.ToFailureKind(kind) == CheatEngineFailureKind.IndeterminateHostResult); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryCompensationStatusMapsToItsFailureAndOnlyAConfirmedReleaseClaimsNothingRemains() + { + MappingTotality.AssertTotal( + static status => ExpectedCompensations.TryGetValue(status, + out (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) expected) && + Describe(AllocationMapping.FromCompensation(status, Allocated, 16, Operation)) == expected, + static status => Describe(AllocationMapping.FromCompensation(status, Allocated, 16, Operation)) == + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed)); + Assert.All(Enum.GetValues().Where(static status => status != TargetReleaseStatus.Released), + static status => Assert.Contains("16 bytes at 0x200000000", + AllocationMapping.FromCompensation(status, Allocated, 16, Operation).Message, StringComparison.Ordinal)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryProtectionMapsToAnExplicitPageProtection() + { + Dictionary expected = new() + { + [AllocationProtection.ReadWrite] = MemoryProtection.ReadWrite, + [AllocationProtection.ExecuteReadWrite] = MemoryProtection.ExecuteReadWrite + }; + + MappingTotality.AssertTotal( + protection => AllocationMapping.TryGetSdkProtection(protection, out MemoryProtection page) && + page == expected[protection], + static protection => !AllocationMapping.TryGetSdkProtection(protection, out MemoryProtection page) && + page == MemoryProtection.None); + } + + [Theory] + [InlineData(EngineEffectState.NotStarted, CheatEngineHostEffect.NotStarted)] + [InlineData(EngineEffectState.NotApplied, CheatEngineHostEffect.NotApplied)] + [InlineData(EngineEffectState.Unknown, CheatEngineHostEffect.Unknown)] + public void AnUnpublishedAllocationTakesItsEffectFromTheSdk(EngineEffectState effect, + CheatEngineHostEffect hostEffect) + { + CheatEngineFailure failure = AllocationMapping.FromUnpublishedAllocation( + new AllocationAttempt(TargetMemoryOperationOutcomeKind.ExpectedFailure, effect, default, null), 16, Operation); + + Assert.Equal(HostEffectMapping.FromSdk(effect), failure.HostEffect); + Assert.Equal(hostEffect, failure.HostEffect); + Assert.Equal(Operation, failure.Operation); + Assert.Null(failure.Exception); + } + + [Fact] + public void AnAppliedAllocationWithoutOwnerOrCompensationMayRemain() + { + CheatEngineFailure failure = AllocationMapping.FromUnpublishedAllocation( + new AllocationAttempt(TargetMemoryOperationOutcomeKind.Succeeded, EngineEffectState.Applied, Allocated, + null), 16, Operation); + + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Contains("16 bytes at 0x200000000", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AFailureThatStillCarriesAnAddressReportsIt() + { + CheatEngineFailure failure = AllocationMapping.FromUnpublishedAllocation( + new AllocationAttempt(TargetMemoryOperationOutcomeKind.MarshallingFailure, EngineEffectState.Unknown, + Allocated, null), 16, Operation); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Contains("16 bytes at 0x200000000", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void TheCompensationTakesPrecedenceOverTheAllocationCategory() + { + CheatEngineFailure failure = AllocationMapping.FromUnpublishedAllocation( + new AllocationAttempt(TargetMemoryOperationOutcomeKind.Succeeded, EngineEffectState.Applied, Allocated, + TargetReleaseStatus.RefusedTargetChanged), 16, Operation); + + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + } + + [Fact] + public void ACancellationAfterTheAllocationClaimsCompletionOnlyForAConfirmedRelease() + { + CheatEngineFailure released = AllocationMapping.CancelledAfterAllocation( + new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), Allocated, 16, Operation); + CheatEngineFailure unconfirmed = AllocationMapping.CancelledAfterAllocation( + new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), Allocated, 16, + Operation); + CheatEngineFailure unavailable = AllocationMapping.CancelledAfterAllocation( + new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), Allocated, 16, + Operation); + + Assert.Equal((CheatEngineFailureKind.Cancelled, CheatEngineHostEffect.Completed), Describe(released)); + Assert.Equal((CheatEngineFailureKind.Cancelled, CheatEngineHostEffect.CleanupUnconfirmed), Describe(unconfirmed)); + Assert.Equal((CheatEngineFailureKind.Cancelled, CheatEngineHostEffect.CleanupUnconfirmed), Describe(unavailable)); + Assert.Contains("16 bytes at 0x200000000", unavailable.Message, StringComparison.Ordinal); + } + + [Fact] + public void AValidRequestKeepsItsFacts() + { + Address preferred = new(0x1000_0000); + + TargetAllocationRequest request = AllocationMapping.CreateRequest( + new AllocationRequest(4096, AllocationProtection.ExecuteReadWrite, preferred)); + + Assert.Equal(4096, request.Size.Value); + Assert.Equal(MemoryProtection.ExecuteReadWrite, request.Protection); + Assert.Equal(preferred, request.PreferredBaseAddress); + } + + /// + /// The default request and a tampered one are programming errors: they throw what the request's constructor + /// throws for the same value, and no SDK request is built from them. + /// + [Theory] + [InlineData("Default")] + [InlineData("NegativeSize")] + [InlineData("UndefinedProtection")] + [InlineData("NullPreferredAddress")] + public void AnInvalidRequestThrowsWhatItsConstructorThrows(string invalid) + { + AllocationRequest valid = new(4096); + AllocationRequest request = invalid switch + { + "Default" => default, + "NegativeSize" => TamperedValues.WithBackingField(valid, nameof(AllocationRequest.Size), -1L), + "UndefinedProtection" => TamperedValues.WithBackingField(valid, nameof(AllocationRequest.Protection), + (AllocationProtection) 9), + "NullPreferredAddress" => TamperedValues.WithBackingField(valid, + nameof(AllocationRequest.PreferredAddress), (Address?) Address.Zero), + _ => throw new ArgumentOutOfRangeException(nameof(invalid), invalid, null) + }; + + ArgumentOutOfRangeException thrown = + Assert.Throws(() => AllocationMapping.CreateRequest(request)); + + Assert.Equal("request", thrown.ParamName); + } + + private static (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) Describe(CheatEngineFailure failure) + { + return (failure.Kind, failure.HostEffect); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/AssemblyClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/AssemblyClientTests.cs new file mode 100644 index 0000000..4c26b86 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/AssemblyClientTests.cs @@ -0,0 +1,778 @@ +#pragma warning disable CECLIENT5003 // These tests exercise the experimental instruction client. + +using System.Collections.Immutable; + +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The experimental instruction client over a fake port (plan L18): one profile observation per call, the bounded +/// assembly buffer and its one retry, the width refusal of a 32-bit profile, the options that reach CheatEngine.SDK, +/// bytes read from target memory, and a target change detected between the profile and the call (Q32). +/// +public sealed class AssemblyClientTests +{ + private static readonly Address Code = new(0x401000); + + private static readonly Address AboveFourGiB = new(0x1_0000_0000); + + private static readonly InstructionProfileObservation X64 = + new(default, new TargetProcessId(4242), CheatEngineArchitecture.X64, PointerSize.Bit64); + + private static readonly InstructionProfileObservation X86 = + new(default, new TargetProcessId(4343), CheatEngineArchitecture.X86, PointerSize.Bit32); + + private readonly CoreLifetime _lifetime = InertCoreLifetime.Create(); + + public static TheoryData Preferences => new() + { + { InstructionEncodingPreference.None, AssemblePreference.None, false }, + { InstructionEncodingPreference.Short, AssemblePreference.Short, true }, + { InstructionEncodingPreference.Long, AssemblePreference.Long, false }, + { InstructionEncodingPreference.Far, AssemblePreference.Far, true } + }; + + [Fact] + [Trait("Qualification", "Q32")] + public void TheProfileIsObservedOnceAndTheOneRetryUsesTheExactRequiredLength() + { + byte[] encoded = [.. Enumerable.Range(1, 20).Select(static value => (byte) value)]; + FakePort port = new() + { + AssembleResults = + { + new AssembleResult(InstructionOperationStatus.DestinationTooSmall, [], 20), + new AssembleResult(InstructionOperationStatus.Success, encoded, 20) + } + }; + AssemblyClient client = CreateClient(port); + + bool assembled = client.TryAssemble(new AssemblyInstructionRequest(Code, "db 01 02"), + out ImmutableArray bytes, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(assembled, failure.Message); + Assert.True(failure.IsDefault); + Assert.Equal(encoded, bytes); + Assert.Equal(1, port.Admissions); + Assert.Equal(1, port.ProfileObservations); + Assert.Equal([AssemblyClient.InitialAssemblyCapacity, 20], port.AssembleCalls.Select(static call => call.Capacity)); + Assert.All(port.AssembleCalls, static call => Assert.Equal(X64, call.Profile)); + } + + [Fact] + public void AResultThatFitsTheFirstBufferIsCopiedWithoutARetry() + { + FakePort port = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.Success, [0x90], 1) } + }; + + ImmutableArray bytes = CreateClient(port).Assemble(new AssemblyInstructionRequest(Code, "nop"), + TestContext.Current.CancellationToken); + + Assert.Equal([0x90], bytes); + Assert.Single(port.AssembleCalls); + } + + [Fact] + public void ASecondTooSmallDestinationIsAResultLimitWithoutAThirdCall() + { + FakePort port = new() + { + AssembleResults = + { + new AssembleResult(InstructionOperationStatus.DestinationTooSmall, [], 20), + new AssembleResult(InstructionOperationStatus.DestinationTooSmall, [], 24) + } + }; + + bool assembled = CreateClient(port).TryAssemble(new AssemblyInstructionRequest(Code, "db 01"), + out ImmutableArray bytes, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(assembled); + Assert.True(bytes.IsDefault); + Assert.Equal(CheatEngineFailureKind.ResultLimitExceeded, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(2, port.AssembleCalls.Count); + } + + [Fact] + public void ARequiredLengthAboveTheLimitIsRefusedWithoutARetry() + { + FakePort port = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.DestinationTooSmall, [], 64) } + }; + AssemblyClient client = CreateClient(port, new MemoryResourceLimits { MaximumReadBytes = 32 }); + + bool assembled = client.TryAssemble(new AssemblyInstructionRequest(Code, "db 01"), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(assembled); + Assert.Equal(CheatEngineFailureKind.ResultLimitExceeded, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Single(port.AssembleCalls); + } + + [Fact] + public void TheFirstBufferNeverExceedsTheConfiguredLimit() + { + FakePort port = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.Success, [0x90], 1) } + }; + AssemblyClient client = CreateClient(port, new MemoryResourceLimits { MaximumReadBytes = 4 }); + + Assert.True(client.TryAssemble(new AssemblyInstructionRequest(Code, "nop"), out _, out _, + TestContext.Current.CancellationToken)); + Assert.Equal(4, Assert.Single(port.AssembleCalls).Capacity); + } + + [Theory] + [MemberData(nameof(Preferences))] + [Trait("Qualification", "Q32")] + public void ThePreferenceTheRangeCheckOptionAndTheOriginReachCheatEngineSdk( + InstructionEncodingPreference preference, AssemblePreference expected, bool skipRangeCheck) + { + FakePort port = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.Success, [0xEB, 0x00], 2) } + }; + + Assert.True(CreateClient(port).TryAssemble( + new AssemblyInstructionRequest(Code, "jmp 00401002", preference, skipRangeCheck), out _, out _, + TestContext.Current.CancellationToken)); + + AssembleCall call = Assert.Single(port.AssembleCalls); + Assert.Equal(expected, call.Preference); + Assert.Equal(skipRangeCheck, call.SkipRangeCheck); + Assert.Equal(Code, call.Address); + Assert.Equal("jmp 00401002", call.Instruction); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void AnX86ProfileRefusesAnAddressAboveFourGiBBeforeCheatEngineIsCalled() + { + FakePort port = new() + { + Profile = X86 + }; + AssemblyClient client = CreateClient(port); + CancellationToken token = TestContext.Current.CancellationToken; + + CheatEngineFailure[] failures = + [ + Failure(() => (client.TryAssemble(new AssemblyInstructionRequest(AboveFourGiB, "nop"), out _, + out CheatEngineFailure f, token), f)), + Failure(() => (client.TryDisassemble(AboveFourGiB, out _, out CheatEngineFailure f, token), f)), + Failure(() => (client.TryGetInstructionLength(AboveFourGiB, out _, out CheatEngineFailure f, token), f)), + Failure(() => (client.TryGetPreviousInstructionAddress(AboveFourGiB, out _, out CheatEngineFailure f, token), f)) + ]; + + Assert.All(failures, static failure => + { + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + }); + Assert.Equal( + [ + AssemblyClient.AssembleOperation, AssemblyClient.DisassembleOperation, + AssemblyClient.GetInstructionLengthOperation, AssemblyClient.GetPreviousInstructionAddressOperation + ], + failures.Select(static failure => failure.Operation)); + Assert.Equal(4, port.ProfileObservations); + Assert.Equal(["Admit", "ObserveProfile", "Admit", "ObserveProfile", "Admit", "ObserveProfile", "Admit", "ObserveProfile"], + port.Calls); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void AnX86ProfileAcceptsAnAddressWithinFourGiB() + { + FakePort port = new() + { + Profile = X86, + Length = (InstructionOperationStatus.Success, 2) + }; + + Assert.Equal(2, CreateClient(port).GetInstructionLength(new Address(uint.MaxValue), + TestContext.Current.CancellationToken)); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void DisassembledBytesAreReadFromTargetMemoryForTheReportedLength() + { + FakePort port = new() + { + Length = (InstructionOperationStatus.Success, 3), + Memory = [0x8B, 0x45, 0x08], + Disassembly = (InstructionOperationStatus.Success, + new InstructionDisassembly(Code, "00401000", "90 90 90", "mov eax,[ebp+08]", "", 26)) + }; + + AssemblyInstructionSnapshot instruction = CreateClient(port).Disassemble(Code, + TestContext.Current.CancellationToken); + + Assert.Equal(Code, instruction.Address); + Assert.Equal(3, instruction.Length); + Assert.Equal(instruction.Length, instruction.Bytes.Length); + Assert.Equal([0x8B, 0x45, 0x08], instruction.Bytes); + Assert.Equal("00401000", instruction.AddressText); + Assert.Equal("mov eax,[ebp+08]", instruction.Opcode); + Assert.Equal("mov eax,[ebp+08]", instruction.Text); + Assert.Equal(["Admit", "ObserveProfile", "GetLength", "ReadBytes", "Disassemble"], port.Calls); + Assert.Equal(MemoryResourceLimits.DefaultMaximumStringBytes, port.DisassemblyBound); + Assert.Equal(3, port.ReadLength); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData("GetLength")] + [InlineData("Disassemble")] + public void ATargetChangeBetweenTheProfileAndTheCallIsDetected(string changedAt) + { + FakePort port = new() + { + Length = changedAt == "GetLength" + ? (InstructionOperationStatus.TargetChanged, 0) + : (InstructionOperationStatus.Success, 1), + Memory = [0x90], + Disassembly = (InstructionOperationStatus.TargetChanged, default) + }; + + bool disassembled = CreateClient(port).TryDisassemble(Code, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(disassembled); + Assert.Equal(default, instruction); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(AssemblyClient.DisassembleOperation, failure.Operation); + Assert.Equal(1, port.ProfileObservations); + Assert.Equal(changedAt == "Disassemble", port.Calls.Contains("ReadBytes")); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void AnAssemblyOnAChangedTargetPublishesNoBytes() + { + FakePort port = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.TargetChanged, [], 0) } + }; + + bool assembled = CreateClient(port).TryAssemble(new AssemblyInstructionRequest(Code, "nop"), + out ImmutableArray bytes, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(assembled); + Assert.True(bytes.IsDefault); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + } + + [Theory] + [InlineData(InstructionOperationStatus.TargetNotSelected, CheatEngineFailureKind.TargetNotAttached)] + [InlineData(InstructionOperationStatus.UnsupportedTargetBackend, CheatEngineFailureKind.Unsupported)] + [InlineData(InstructionOperationStatus.InvalidProfile, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(InstructionOperationStatus.LuaFailure, CheatEngineFailureKind.LuaError)] + public void AFailedProfileObservationStopsTheCallBeforeAnyInstructionGlobal(InstructionOperationStatus status, + CheatEngineFailureKind expected) + { + FakePort port = new() + { + ProfileStatus = status + }; + + bool measured = CreateClient(port).TryGetInstructionLength(Code, out int length, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(measured); + Assert.Equal(0, length); + Assert.Equal(expected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(["Admit", "ObserveProfile"], port.Calls); + } + + [Fact] + public void ARejectedInstructionAppliedNothing() + { + FakePort port = new() + { + AssembleResults = + { + new AssembleResult(InstructionOperationStatus.InstructionRejected, [], 0), + new AssembleResult(InstructionOperationStatus.InstructionRejected, [], 0) + } + }; + AssemblyClient client = CreateClient(port); + + bool assembled = client.TryAssemble(new AssemblyInstructionRequest(Code, "notAnOpcode"), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(assembled); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.Equal(failure, + Assert.Throws(() => + client.Assemble(new AssemblyInstructionRequest(Code, "notAnOpcode"), + TestContext.Current.CancellationToken)).Failure); + } + + [Fact] + public void APartialByteReadPublishesNoInstruction() + { + FakePort port = new() + { + Length = (InstructionOperationStatus.Success, 4), + Memory = [0x0F, 0x1F], + ReadFailure = MemoryAccessFailure.PartialRead + }; + + bool disassembled = CreateClient(port).TryDisassemble(Code, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(disassembled); + Assert.Equal(default, instruction); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Contains("2 of the 4", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("Disassemble", port.Calls); + } + + [Theory] + [InlineData("ReadBytes")] + [InlineData("Disassemble")] + public void AStepRefusedAfterTheLengthQueryIsCompletedNotNotStarted(string refusedAt) + { + FakePort port = new() + { + Length = (InstructionOperationStatus.Success, 1), + Memory = [0x90], + ReadFailure = refusedAt == "ReadBytes" ? MemoryAccessFailure.GlobalUnavailable : MemoryAccessFailure.None, + Disassembly = (InstructionOperationStatus.GlobalUnavailable, default) + }; + + bool disassembled = CreateClient(port).TryDisassemble(Code, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(disassembled); + Assert.Equal(default, instruction); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("GetLength", port.Calls[2]); + Assert.Equal(refusedAt, port.Calls[^1]); + } + + [Fact] + public void AnUnavailableAssemblerIsNotStartedOnlyBeforeTheFirstCall() + { + FakePort first = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.GlobalUnavailable, [], 0) } + }; + FakePort retry = new() + { + AssembleResults = + { + new AssembleResult(InstructionOperationStatus.DestinationTooSmall, [], 20), + new AssembleResult(InstructionOperationStatus.GlobalUnavailable, [], 0) + } + }; + CancellationToken token = TestContext.Current.CancellationToken; + + Assert.False(CreateClient(first).TryAssemble(new AssemblyInstructionRequest(Code, "nop"), out _, + out CheatEngineFailure beforeCall, token)); + Assert.False(CreateClient(retry).TryAssemble(new AssemblyInstructionRequest(Code, "db 01"), out _, + out CheatEngineFailure afterCall, token)); + + Assert.Equal((CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted), + (beforeCall.Kind, beforeCall.HostEffect)); + Assert.Equal((CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.Completed), + (afterCall.Kind, afterCall.HostEffect)); + Assert.Equal(2, retry.AssembleCalls.Count); + } + + [Fact] + public void AnEmptyAssemblyIsAnInvalidHostResult() + { + FakePort port = new() + { + AssembleResults = { new AssembleResult(InstructionOperationStatus.Success, [], 0) } + }; + AssemblyClient client = CreateClient(port); + + bool assembled = client.TryAssemble(new AssemblyInstructionRequest(Code, "nop"), + out ImmutableArray bytes, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(assembled); + Assert.True(bytes.IsDefault); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Contains("empty", failure.Message, StringComparison.Ordinal); + Assert.Single(port.AssembleCalls); + } + + [Fact] + public void AnInstructionLongerThanTheReadLimitIsNotRead() + { + FakePort port = new() + { + Length = (InstructionOperationStatus.Success, 16) + }; + AssemblyClient client = CreateClient(port, new MemoryResourceLimits { MaximumReadBytes = 8 }); + + bool disassembled = client.TryDisassemble(Code, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(disassembled); + Assert.Equal(CheatEngineFailureKind.ResultLimitExceeded, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.DoesNotContain("ReadBytes", port.Calls); + } + + [Theory] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("nop", true)] + public void ADisassemblyWithoutAnOpcodeIsAnInvalidHostResult(string opcode, bool expected) + { + FakePort port = new() + { + Length = (InstructionOperationStatus.Success, 1), + Memory = [0x90], + Disassembly = (InstructionOperationStatus.Success, + new InstructionDisassembly(Code, "00401000", "90", opcode, "", 12)) + }; + + bool disassembled = CreateClient(port).TryDisassemble(Code, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.Equal(expected, disassembled); + Assert.Equal(expected ? default : CheatEngineFailureKind.InvalidHostResult, failure.Kind); + } + + [Fact] + public void TheInstructionLengthIsCheatEnginesPositiveLength() + { + FakePort measured = new() + { + Length = (InstructionOperationStatus.Success, 5) + }; + FakePort malformed = new() + { + Length = (InstructionOperationStatus.Success, 0) + }; + + Assert.Equal(5, CreateClient(measured).GetInstructionLength(Code, TestContext.Current.CancellationToken)); + Assert.False(CreateClient(malformed).TryGetInstructionLength(Code, out int length, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + Assert.Equal(0, length); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + } + + [Fact] + public void ThePreviousInstructionIsCheatEnginesEstimateWithinTheProfileWidth() + { + FakePort estimated = new() + { + Previous = (InstructionOperationStatus.Success, new Address(0x400FFE)) + }; + FakePort refused = new() + { + Previous = (InstructionOperationStatus.AddressExceedsProfileWidth, Address.Zero) + }; + FakePort tooWide = new() + { + Profile = X86, + Previous = (InstructionOperationStatus.Success, AboveFourGiB) + }; + CancellationToken token = TestContext.Current.CancellationToken; + + Assert.Equal(new Address(0x400FFE), CreateClient(estimated).GetPreviousInstructionAddress(Code, token)); + foreach (FakePort port in (FakePort[]) [refused, tooWide]) + { + Assert.False(CreateClient(port).TryGetPreviousInstructionAddress(Code, out Address previous, + out CheatEngineFailure failure, token)); + Assert.Equal(default, previous); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + } + } + + [Fact] + public void ARefusedLuaAdmissionIsReportedWithoutAProfileObservation() + { + CheatEngineFailure refusal = new(CheatEngineFailureKind.ActivationExpired, AssemblyClient.AssembleOperation, + "Detached.", null, CheatEngineHostEffect.NotStarted); + FakePort port = new() + { + AdmissionRefusal = refusal + }; + + bool assembled = CreateClient(port).TryAssemble(new AssemblyInstructionRequest(Code, "nop"), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(assembled); + Assert.Equal(refusal, failure); + Assert.Equal(["Admit"], port.Calls); + } + + [Fact] + public void CancellationIsObservedBeforeDispatchOnly() + { + FakePort port = new(); + AssemblyClient client = CreateClient(port); + CancellationToken cancelled = new(true); + + Assert.False(client.TryDisassemble(Code, out _, out CheatEngineFailure failure, cancelled)); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(AssemblyClient.DisassembleOperation, failure.Operation); + CheatEngineOperationCanceledException exception = Assert.Throws(() => + client.GetPreviousInstructionAddress(Code, cancelled)); + Assert.Equal(cancelled, exception.CancellationToken); + Assert.Empty(port.Calls); + } + + [Fact] + public void TheDefaultRequestAndAnEndedActivationAreRejectedBeforeAnyWork() + { + FakePort port = new(); + using ControlledCoreLifetimeContext context = new() + { + IsCurrent = false + }; + AssemblyClient ended = new(new SdkMainThreadDispatcher(new CoreLifetime(context), new InlineMainThreadInvoker()), + new CoreLifetime(context), new MemoryResourceLimits(), port); + + Assert.Throws(() => + CreateClient(port).TryAssemble(default, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => + ended.TryGetInstructionLength(Code, out _, out _, TestContext.Current.CancellationToken)); + Assert.Empty(port.Calls); + } + + [Fact] + public void AnSdkFaultInsideTheCallNeverCrossesTheTryForm() + { + InvalidOperationException fault = new("detached runtime"); + FakePort port = new() + { + LengthFault = fault + }; + + bool measured = CreateClient(port).TryGetInstructionLength(Code, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(measured); + Assert.Same(fault, failure.Exception); + Assert.Equal(AssemblyClient.GetInstructionLengthOperation, failure.Operation); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + } + + private static CheatEngineFailure Failure(Func<(bool Succeeded, CheatEngineFailure Failure)> attempt) + { + (bool succeeded, CheatEngineFailure failure) = attempt(); + Assert.False(succeeded); + return failure; + } + + private AssemblyClient CreateClient(FakePort port, MemoryResourceLimits? limits = null) + { + return new AssemblyClient(new SdkMainThreadDispatcher(_lifetime, new InlineMainThreadInvoker()), _lifetime, + limits ?? new MemoryResourceLimits(), port); + } + + private sealed record AssembleResult(InstructionOperationStatus Status, byte[] Bytes, int RequiredLength); + + private sealed record AssembleCall( + InstructionProfileObservation Profile, + string Instruction, + Address Address, + AssemblePreference Preference, + bool SkipRangeCheck, + int Capacity); + + /// A port that records every call and answers from its configured results. + private sealed class FakePort : IInstructionPort + { + internal List Calls + { + get; + } = []; + + internal CheatEngineFailure? AdmissionRefusal + { + get; + init; + } + + internal InstructionOperationStatus ProfileStatus + { + get; + init; + } = InstructionOperationStatus.Success; + + internal InstructionProfileObservation Profile + { + get; + init; + } = X64; + + internal List AssembleResults + { + get; + } = []; + + internal List AssembleCalls + { + get; + } = []; + + internal (InstructionOperationStatus Status, int Length) Length + { + get; + init; + } = (InstructionOperationStatus.Success, 1); + + internal Exception? LengthFault + { + get; + init; + } + + internal (InstructionOperationStatus Status, InstructionDisassembly Disassembly) Disassembly + { + get; + init; + } = (InstructionOperationStatus.Success, new InstructionDisassembly(Code, "00401000", "90", "nop", "", 12)); + + internal (InstructionOperationStatus Status, Address Previous) Previous + { + get; + init; + } + + internal byte[] Memory + { + get; + init; + } = [0x90]; + + internal MemoryAccessFailure ReadFailure + { + get; + init; + } + + internal int Admissions => Calls.Count(static call => call == "Admit"); + + internal int ProfileObservations => Calls.Count(static call => call == "ObserveProfile"); + + internal int DisassemblyBound + { + get; + private set; + } + + internal int ReadLength + { + get; + private set; + } + + public bool TryRunAdmitted(string operation, Action work, out CheatEngineFailure admissionFailure) + { + Calls.Add("Admit"); + if (AdmissionRefusal is { } refusal) + { + admissionFailure = refusal; + return false; + } + + admissionFailure = default; + work(); + return true; + } + + public InstructionOperationStatus ObserveProfile(out InstructionProfileObservation profile) + { + Calls.Add("ObserveProfile"); + profile = ProfileStatus == InstructionOperationStatus.Success ? Profile : default; + return ProfileStatus; + } + + public InstructionOperationStatus Assemble(InstructionProfileObservation profile, string instruction, + Address address, AssemblePreference preference, bool skipRangeCheck, Span destination, + out int written, out int requiredLength) + { + Calls.Add("Assemble"); + AssembleCalls.Add(new AssembleCall(profile, instruction, address, preference, skipRangeCheck, + destination.Length)); + AssembleResult result = AssembleResults[AssembleCalls.Count - 1]; + requiredLength = result.RequiredLength; + if (result.Status != InstructionOperationStatus.Success) + { + written = 0; + return result.Status; + } + + result.Bytes.CopyTo(destination); + written = result.Bytes.Length; + return result.Status; + } + + public InstructionOperationStatus Disassemble(InstructionProfileObservation profile, Address address, + int maximumUtf8Bytes, out InstructionDisassembly disassembly) + { + Calls.Add("Disassemble"); + DisassemblyBound = maximumUtf8Bytes; + disassembly = Disassembly.Status == InstructionOperationStatus.Success ? Disassembly.Disassembly : default; + return Disassembly.Status; + } + + public InstructionOperationStatus GetLength(InstructionProfileObservation profile, Address address, + out int length) + { + Calls.Add("GetLength"); + if (LengthFault is { } fault) + { + throw fault; + } + + length = Length.Length; + return Length.Status; + } + + public InstructionOperationStatus GetPrevious(InstructionProfileObservation profile, Address address, + out Address previous) + { + Calls.Add("GetPrevious"); + previous = Previous.Previous; + return Previous.Status; + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + Calls.Add("ReadBytes"); + ReadLength = destination.Length; + int copied = Math.Min(Memory.Length, destination.Length); + Memory.AsSpan(0, copied).CopyTo(destination); + written = copied; + failure = ReadFailure; + return ReadFailure == MemoryAccessFailure.None && copied == destination.Length; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/AutoAssemblerClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/AutoAssemblerClientTests.cs new file mode 100644 index 0000000..6f29ba9 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/AutoAssemblerClientTests.cs @@ -0,0 +1,1145 @@ +#pragma warning disable CECLIENT5004 // These tests exercise the experimental Auto Assembler client. + +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The opt-in Auto Assembler client over a fake port (plan L17): the policy refusal without the opt-in (Q44), the +/// activation and release of a patch lease (Q35), every mapped outcome, and the lease rules (one disable attempt, +/// refusal on a changed target, a Dispose that never throws). A patch applied in a process selected in Cheat +/// Engine's own window stays with that process, and a registration the activation or the selection refused reports +/// what the release of the patch left. +/// +public sealed class AutoAssemblerClientTests : IDisposable +{ + private const string Script = "[ENABLE]\r\nalloc(probe,4)\r\n[DISABLE]\r\ndealloc(probe)"; + + private readonly ControlledCoreLifetimeContext _context = new(); + private readonly RecordingDiagnostics _diagnostics = new(); + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly TrackingInvoker _invoker = new(); + private readonly CoreLifetime _lifetime; + private readonly FakePort _port; + private readonly ProcessClient _processes; + private readonly FakeSelectedTarget _target = new(); + + public AutoAssemblerClientTests() + { + _diagnostics.Invoker = _invoker; + _lifetime = new CoreLifetime(_context, _diagnostics); + _dispatcher = new SdkMainThreadDispatcher(_lifetime, _invoker); + _port = new FakePort(_invoker); + _processes = FakeSelectedTarget.CreateProcessClient(_dispatcher, _target); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + public void Dispose() + { + _context.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q44")] + public void WithoutTheOptInEveryCallIsRefusedBeforeAnyCheatEngineCall() + { + AutoAssemblerClient client = CreateClient(enabled: false); + CancellationToken token = TestContext.Current.CancellationToken; + + bool applied = client.TryApplyPatch(new AutoAssemblerScript(Script), out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure applyFailure, token); + bool checkedScript = client.TryCheck(new AutoAssemblerScript(Script), out AutoAssemblerCheckResult result, + out CheatEngineFailure checkFailure, token); + + Assert.False(applied); + Assert.Null(lease); + Assert.False(checkedScript); + Assert.Equal(default, result); + AssertPolicyRefusal(applyFailure, AutoAssemblerClient.ApplyOperation); + AssertPolicyRefusal(checkFailure, AutoAssemblerClient.CheckOperation); + Assert.Equal(0, _port.ApplyCalls); + Assert.Equal(0, _port.CheckCalls); + Assert.Equal(0, _invoker.Calls); + Assert.Equal( + [ + (ClientCapabilityId.AutoAssemblerPatches.Value, AutoAssemblerClient.ApplyOperation), + (ClientCapabilityId.AutoAssemblerPatches.Value, AutoAssemblerClient.CheckOperation) + ], + _diagnostics.Refusals); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, + Assert.Throws(() => client.ApplyPatch(new AutoAssemblerScript(Script), token)) + .Failure.Kind); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, + Assert.Throws(() => client.Check(new AutoAssemblerScript(Script), token)) + .Failure.Kind); + } + + [Fact] + [Trait("Qualification", "Q44")] + public void ThePolicyEnablesAutoAssemblerPatchesOnlyThroughItsOwnOptIn() + { + Assert.False(CoreClientPolicy.SafeDefaults.EnableAutoAssemblerPatches); + Assert.False(new CoreClientPolicy([], true).EnableAutoAssemblerPatches); + Assert.True(new CoreClientPolicy([], false, enableAutoAssemblerPatches: true).EnableAutoAssemblerPatches); + Assert.False(new CoreClientPolicy([], false, enableAutoAssemblerPatches: true).EnableUnsafeLuaExecution); + } + + [Fact] + public void CancellationIsObservedBeforeThePolicyGateAndBeforeDispatch() + { + AutoAssemblerClient refused = CreateClient(enabled: false); + AutoAssemblerClient enabled = CreateClient(); + CancellationToken cancelled = new(true); + + Assert.False(refused.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure failure, + cancelled)); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Empty(_diagnostics.Refusals); + CheatEngineOperationCanceledException exception = Assert.Throws(() => + enabled.ApplyPatch(new AutoAssemblerScript(Script), cancelled)); + Assert.Equal(CheatEngineHostEffect.NotStarted, exception.Failure.HostEffect); + Assert.Throws(() => + enabled.Check(new AutoAssemblerScript(Script), cancelled)); + Assert.Equal(0, _invoker.Calls); + Assert.Equal(0, _port.ApplyCalls); + } + + [Fact] + public void TheDefaultScriptAndAnEndedActivationAreRejectedBeforeAnyWork() + { + AutoAssemblerClient client = CreateClient(); + CancellationToken token = TestContext.Current.CancellationToken; + + Assert.Throws(() => client.TryApplyPatch(default, out _, out _, token)); + Assert.Throws(() => client.TryCheck(default, out _, out _, token)); + _context.IsCurrent = false; + Assert.Throws(() => + client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out _, token)); + Assert.Equal(0, _invoker.Calls); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void AnAppliedPatchIsATargetBoundLeaseThatDisablesOnceOnTheMainThread() + { + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.Applied, warnings: "unused label", warningsTruncated: true); + AutoAssemblerClient client = CreateClient(); + + bool applied = client.TryApplyPatch(new AutoAssemblerScript(Script, "probe.patch"), + out IAutoAssemblerPatchLease? lease, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(applied); + Assert.Equal(default, failure); + Assert.NotNull(lease); + Assert.Equal([Script], _port.Scripts); + Assert.Same(AutoAssemblerClient.Options, _port.LastOptions); + Assert.True(_port.LastOptions!.CaptureHostText); + Assert.Equal(AutoAssemblerClient.HostTextByteLimit, _port.LastOptions.MaxHostTextBytes); + Assert.Equal(AutoAssemblerOptions.MinDisableInfoEntries, _port.LastOptions.MaxDisableInfoEntries); + Assert.Equal(AutoAssemblerOptions.MinDisableInfoNameBytes, _port.LastOptions.MaxDisableInfoNameBytes); + Assert.True(_port.RanOnMainThread); + Assert.Equal("probe.patch", lease.Name); + Assert.Equal(0, lease.SelectionEpoch); + Assert.False(lease.AppliedAfterTargetChange); + Assert.Equal("unused label", lease.HostWarnings); + Assert.True(lease.HostWarningsTruncated); + Assert.True(lease.CanDisable); + Assert.False(lease.IsReleased); + Assert.False(lease.RequiresManualRecovery); + Assert.Empty(_diagnostics.TargetChangeWarnings); + + LeaseReleaseOutcome outcome = lease.Release(); + LeaseReleaseOutcome repeated = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), outcome); + Assert.Equal(outcome, repeated); + Assert.Equal(1, _port.Owner!.ReleaseCalls); + Assert.Equal([true], _port.Owner.ReleasedOnMainThread); + Assert.True(lease.IsReleased); + Assert.False(lease.CanDisable); + Assert.False(lease.RequiresManualRecovery); + Assert.Equal(outcome, lease.LastReleaseOutcome); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void AnActivationAppliedWhileTheTargetChangedIsReturnedWithAWarning() + { + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.AppliedTargetChanged); + AutoAssemblerClient client = CreateClient(); + + IAutoAssemblerPatchLease lease = + client.ApplyPatch(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken); + + Assert.True(lease.AppliedAfterTargetChange); + Assert.Equal([(AutoAssemblerClient.ApplyOperation, 0L, false)], _diagnostics.TargetChangeWarnings); + } + + [Theory] + [Trait("Qualification", "Q35")] + [InlineData(AutoAssemblerApplyOutcomeKind.Rejected, CheatEngineFailureKind.OperationRejected, + CheatEngineHostEffect.Unknown)] + [InlineData(AutoAssemblerApplyOutcomeKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.NotStarted)] + [InlineData(AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure, CheatEngineFailureKind.LuaError, + CheatEngineHostEffect.Unknown)] + [InlineData(AutoAssemblerApplyOutcomeKind.InvalidResult, CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.Unknown)] + [InlineData(AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable, + CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.NotStarted)] + [InlineData(AutoAssemblerApplyOutcomeKind.HandoffFailed, CheatEngineFailureKind.BindingError, + CheatEngineHostEffect.CleanupUnconfirmed)] + [InlineData(AutoAssemblerApplyOutcomeKind.Unknown, CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.Unknown)] + public void AFailedActivationIsMappedWithoutALease(AutoAssemblerApplyOutcomeKind kind, + CheatEngineFailureKind expectedKind, CheatEngineHostEffect expectedEffect) + { + _port.ApplyFacts = Facts(kind); + _port.Owner = null; + AutoAssemblerClient client = CreateClient(); + + bool applied = client.TryApplyPatch(new AutoAssemblerScript(Script), out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(applied); + Assert.Null(lease); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.Equal(AutoAssemblerClient.ApplyOperation, failure.Operation); + Assert.Null(failure.Exception); + Assert.Empty(_diagnostics.TargetChangeWarnings); + } + + [Fact] + public void ARejectionCarriesCheatEnginesBoundedTextAndAHandoffFailureItsCompensation() + { + _port.Owner = null; + AutoAssemblerClient client = CreateClient(); + CancellationToken token = TestContext.Current.CancellationToken; + + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.Rejected, "Error in line 2", hostTextTruncated: true); + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure rejected, + token)); + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.HandoffFailed, + compensation: TargetReleaseStatus.Released); + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure handoff, token)); + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure); + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure lua, token)); + + Assert.EndsWith("Cheat Engine reported: Error in line 2 [truncated]", rejected.Message, StringComparison.Ordinal); + Assert.Contains("ended as Released", handoff.Message, StringComparison.Ordinal); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, handoff.HostEffect); + Assert.Contains(LuaStatus.RuntimeError.ToString(), lua.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnOwnerNextToAFailureIsReleasedAndAnAppliedOutcomeWithoutAnOwnerIsIndeterminate() + { + AutoAssemblerClient client = CreateClient(); + CancellationToken token = TestContext.Current.CancellationToken; + FakeOwner stray = new(_invoker); + _port.Owner = stray; + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.Rejected); + + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure rejected, + token)); + _port.Owner = null; + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.Applied); + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure ownerless, + token)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, rejected.Kind); + Assert.Equal(1, stray.ReleaseCalls); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, ownerless.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, ownerless.HostEffect); + } + + [Fact] + public void AnOwnerNextToAFailureWhoseReleaseIsNotCompleteLeavesTheCleanupUnconfirmed() + { + AutoAssemblerClient client = CreateClient(); + FakeOwner stray = new(_invoker) + { + ReleaseStatus = TargetReleaseStatus.UnconfirmedAfterInvocation + }; + _port.Owner = stray; + _port.ApplyFacts = Facts(AutoAssemblerApplyOutcomeKind.Rejected); + + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure rejected, + TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, rejected.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, rejected.HostEffect); + Assert.Equal(1, stray.ReleaseCalls); + } + + [Fact] + public void ARefusedLuaAdmissionAndAnSdkFaultAreReturnedAsFailures() + { + AutoAssemblerClient client = CreateClient(); + CancellationToken token = TestContext.Current.CancellationToken; + CheatEngineFailure refusal = new(CheatEngineFailureKind.ActivationExpired, AutoAssemblerClient.ApplyOperation, + "Detached.", null, CheatEngineHostEffect.NotStarted); + _port.Admission = refusal; + + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure refused, + token)); + Assert.False(client.TryCheck(new AutoAssemblerScript(Script), out _, out CheatEngineFailure refusedCheck, + token)); + _port.Admission = null; + EngineGlobalUnavailableException fault = new("AutoAssemblerApply"); + _port.Fault = fault; + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out CheatEngineFailure faulted, + token)); + + Assert.Equal(refusal, refused); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, refusedCheck.Kind); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, faulted.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, faulted.HostEffect); + Assert.Same(fault, faulted.Exception); + } + + /// + /// A patch the port could not publish, whose one disable was not confirmed, is reported like the AOB result list: + /// the publication fault keeps its classification and the unconfirmed release makes it CleanupUnconfirmed. + /// + [Theory] + [Trait("Qualification", "Q35")] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, "CleanupUnconfirmed")] + [InlineData(TargetReleaseStatus.NotInvoked, "RefusedRuntimeChanged")] + public void APatchWhosePublicationFailedAndWhoseDisableWasNotConfirmedIsCleanupUnconfirmed( + TargetReleaseStatus disable, string expectedKind) + { + InvalidOperationException publishFailure = new("the patch owner could not be published"); + AutoAssemblerClient client = CreateClient(); + // The handoff releases the unpublished patch with the production port's own mapping. + _port.DuringApply = () => _ = OwnershipHandoff.Adopt(new object(), _ => throw publishFailure, + _ => SdkAutoAssemblerPort.MapUnpublishedRelease(disable)); + + Assert.False(client.TryApplyPatch(new AutoAssemblerScript(Script), out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, Token)); + + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal(AutoAssemblerClient.ApplyOperation, failure.Operation); + Assert.Same(publishFailure, failure.Exception); + Assert.EndsWith($"The applied Auto Assembler patch release was not confirmed ({expectedKind}).", + failure.Message, StringComparison.Ordinal); + Assert.Equal(0, _port.Owner!.ReleaseCalls); + } + + /// + /// The production port releases a patch it could not publish with the lease's mapping, not the shared one: a + /// disable that could not begin is the terminal RefusedRuntimeChanged, since CheatEngine.SDK consumed the disable + /// information, never the retryable CleanupUnavailable. + /// + [Fact] + public void ThePortMapsTheDisableOfAnUnpublishedPatchLikeTheLease() + { + foreach (TargetReleaseStatus status in Enum.GetValues()) + { + Assert.Equal(AutoAssemblerMapping.ToReleaseOutcome(status), + SdkAutoAssemblerPort.MapUnpublishedRelease(status)); + } + + LeaseReleaseOutcome notInvoked = SdkAutoAssemblerPort.MapUnpublishedRelease(TargetReleaseStatus.NotInvoked); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + notInvoked); + Assert.NotEqual(SdkReleaseOutcomes.FromTarget(TargetReleaseStatus.NotInvoked), notInvoked); + } + + [Theory] + [Trait("Qualification", "Q35")] + [InlineData(TargetReleaseStatus.Released, CheatEngineHostEffect.Completed, "which was released at once")] + [InlineData(TargetReleaseStatus.RefusedTargetChanged, CheatEngineHostEffect.CleanupUnconfirmed, + "ended with RefusedTargetChanged")] + public void ALeaseThatCannotBeRegisteredIsReleasedAtOnce(TargetReleaseStatus release, + CheatEngineHostEffect expectedEffect, string expected) + { + _port.Owner!.ReleaseStatus = release; + AutoAssemblerClient client = new(_dispatcher, new CoreClientPolicy([], false, true), _lifetime, + new StaleSelectionBinder(), _port); + + bool applied = client.TryApplyPatch(new AutoAssemblerScript(Script), out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, Token); + + Assert.False(applied); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.IsType(failure.Exception); + Assert.Contains(expected, failure.Message, StringComparison.Ordinal); + Assert.Equal(1, _port.Owner.ReleaseCalls); + Assert.Equal([true], _port.Owner.ReleasedOnMainThread); + Assert.Throws(() => client.ApplyPatch(new AutoAssemblerScript(Script), Token)); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void AnActivationDuringTheDeactivationCleanupIsRefusedBeforeCheatEngineApplies() + { + AutoAssemblerClient client = CreateClient(); + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + Assert.Throws(() => + client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out _, Token)); + } + + // Like every lease-creating operation, an activation is admitted with ThrowIfInactive, which makes no exception for + // a cleanup scope: the admission refuses it before anything is dispatched, and nothing is applied or released. + Assert.Equal(0, _invoker.Calls); + Assert.Equal(0, _port.ApplyCalls); + Assert.Equal(0, _port.Owner!.ReleaseCalls); + } + + [Theory] + [Trait("Qualification", "Q43")] + [InlineData(false, typeof(CheatEngineInvalidStateException))] + [InlineData(true, typeof(CheatEngineActivationExpiredException))] + public void AnActivationThatStopsOrEndsAfterItsAdmissionIsRefusedBeforeCheatEngineApplies(bool ends, + Type expected) + { + AutoAssemblerClient client = CreateClient(); + // The admission and the dispatch let the activation through; it stops or ends before the callback runs on + // Cheat Engine's main thread, so only the callback's own check can refuse it. + _invoker.BeforeCallback = () => + { + if (ends) + { + _context.IsCurrent = false; + } + else + { + _context.Stop(); + } + }; + + CheatEngineClientException refused = Assert.ThrowsAny(() => + client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out _, Token)); + + Assert.IsType(expected, refused); + Assert.Equal(AutoAssemblerClient.ApplyOperation, refused.Failure.Operation); + Assert.Equal(1, _invoker.Calls); + Assert.Equal(0, _port.ApplyCalls); + Assert.Equal(0, _port.Owner!.ReleaseCalls); + } + + [Theory] + [Trait("Qualification", "Q43")] + [InlineData(TargetReleaseStatus.Released, "which was released at once")] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, "ended with CleanupUnconfirmed")] + public void AnActivationStoppingDuringTheApplyThrowsWhatTheReleaseLeft(TargetReleaseStatus release, + string expected) + { + _port.Owner!.ReleaseStatus = release; + _port.DuringApply = _context.Stop; + AutoAssemblerClient client = CreateClient(); + + CheatEngineInvalidStateException stopping = Assert.Throws(() => + client.TryApplyPatch(new AutoAssemblerScript(Script), out _, out _, Token)); + + Assert.Contains(expected, stopping.Message, StringComparison.Ordinal); + Assert.Equal(AutoAssemblerClient.ApplyOperation, stopping.Failure.Operation); + Assert.Equal(1, _port.Owner.ReleaseCalls); + Assert.Equal([true], _port.Owner.ReleasedOnMainThread); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void ATargetChangeObservedDuringTheApplyKeysTheLeaseToTheProcessItWasAppliedIn() + { + _ = _processes.GetCurrentProcess(Token); + _port.Owner!.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + // CheatEngine.SDK bound the patch to process 42; Cheat Engine then selects process 43, which the Client + // observes before the lease is registered. + _port.DuringApply = () => + { + _target.Select(FakeSelectedTarget.OtherProcessIncarnation); + _ = _processes.GetCurrentProcess(Token); + }; + AutoAssemblerClient client = CreateClient(); + + IAutoAssemblerPatchLease lease = client.ApplyPatch(new AutoAssemblerScript(Script), Token); + bool releasedWhenPublished = lease.IsReleased; + ProcessSnapshot observed = _processes.GetCurrentProcess(Token); + + Assert.False(releasedWhenPublished); + // The next observation of process 43 ends the lease of process 42: CheatEngine.SDK refuses the disable there. + Assert.True(observed.SelectionEpoch > lease.SelectionEpoch); + Assert.True(lease.IsReleased); + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, lease.LastReleaseOutcome?.Kind); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(1, _port.Owner.ReleaseCalls); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void APatchAppliedInAProcessSelectedInCheatEngineStaysWithThatProcess() + { + long observedEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + AutoAssemblerClient client = CreateClient(); + FakeOwner first = _port.Owner!; + first.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + IAutoAssemblerPatchLease inFirst = client.ApplyPatch(new AutoAssemblerScript(Script), Token); + // Cheat Engine's own window selects another process: no Client call observes it. + _target.Select(FakeSelectedTarget.OtherProcessIncarnation); + FakeOwner second = new(_invoker) + { + TargetIncarnation = FakeSelectedTarget.OtherProcessIncarnation + }; + _port.Owner = second; + + IAutoAssemblerPatchLease inSecond = client.ApplyPatch(new AutoAssemblerScript(Script), Token); + long secondEpoch = inSecond.SelectionEpoch; + ProcessSnapshot observed = _processes.GetCurrentProcess(Token); + + // The first patch's process is no longer selected: its lease ended when the second patch was bound, and + // CheatEngine.SDK refused to disable it in the new target. + Assert.Equal(observedEpoch, inFirst.SelectionEpoch); + Assert.True(inFirst.IsReleased); + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, inFirst.LastReleaseOutcome?.Kind); + Assert.Equal(1, first.ReleaseCalls); + // The second patch belongs to the selection the next observation finds, which releases nothing. + Assert.True(secondEpoch > observedEpoch); + Assert.Equal(secondEpoch, observed.SelectionEpoch); + Assert.Equal(new TargetProcessId(43), observed.Id); + Assert.False(inSecond.IsReleased); + Assert.True(inSecond.CanDisable); + Assert.Equal(0, second.ReleaseCalls); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void APatchInTheObservedProcessKeepsTheObservedSelection() + { + long observedEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + AutoAssemblerClient client = CreateClient(); + + IAutoAssemblerPatchLease lease = client.ApplyPatch(new AutoAssemblerScript(Script), Token); + ProcessSnapshot observed = _processes.GetCurrentProcess(Token); + + Assert.Equal(observedEpoch, lease.SelectionEpoch); + Assert.Equal(observedEpoch, observed.SelectionEpoch); + Assert.False(lease.IsReleased); + Assert.Equal(0, _port.Owner!.ReleaseCalls); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void SelectingAnotherTargetEndsTheLeaseWithoutDisablingThePatch() + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = + client.ApplyPatch(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken); + // The Client observes the change after Cheat Engine already targets the new process, so CheatEngine.SDK refuses + // the disable there and consumes the disable information: the patch stays in the previous process. + _port.Owner!.ReleaseStatus = TargetReleaseStatus.RefusedTargetChanged; + + _ = _lifetime.TargetSelection.Advance("Processes.Attach"); + LeaseReleaseOutcome repeated = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + lease.LastReleaseOutcome); + Assert.True(lease.IsReleased); + Assert.True(lease.RequiresManualRecovery); + Assert.False(lease.CanDisable); + Assert.Equal(lease.LastReleaseOutcome, repeated); + Assert.Equal(1, _port.Owner.ReleaseCalls); + } + + [Theory] + [InlineData(TargetReleaseStatus.RefusedRuntimeChanged, LeaseReleaseKind.RefusedRuntimeChanged)] + [InlineData(TargetReleaseStatus.NotInvoked, LeaseReleaseKind.RefusedRuntimeChanged)] + [InlineData(TargetReleaseStatus.RefusedIdentityUnavailable, LeaseReleaseKind.RefusedTargetIdentityUnavailable)] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, LeaseReleaseKind.CleanupUnconfirmed)] + public void AReleaseThatConsumedTheDisableInformationWithoutConfirmationRequiresManualRecovery( + TargetReleaseStatus status, LeaseReleaseKind expected) + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = + client.ApplyPatch(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken); + _port.Owner!.ReleaseStatus = status; + + LeaseReleaseOutcome outcome = lease.Release(); + + Assert.Equal(expected, outcome.Kind); + Assert.True(outcome.RequiresManualRecovery); + Assert.True(lease.RequiresManualRecovery); + Assert.True(lease.IsReleased); + } + + /// + /// CheatEngine.SDK 2.0.0 sets its manual-recovery flag only inside a release attempt. After Cheat Engine replaced + /// its Lua state, the owner reports that it cannot disable and no flag: CanDisable is the early signal, and + /// only the refused release makes the lease require manual recovery. + /// + [Fact] + public void ALostDisableInformationClearsCanDisableAndOnlyItsRefusedReleaseRequiresManualRecovery() + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = client.ApplyPatch(new AutoAssemblerScript(Script), Token); + _port.Owner!.LostDisableInformation = true; + _port.Owner.ReleaseStatus = TargetReleaseStatus.RefusedRuntimeChanged; + + bool canDisableBefore = lease.CanDisable; + bool manualRecoveryBefore = lease.RequiresManualRecovery; + LeaseReleaseOutcome outcome = lease.Release(); + + Assert.False(canDisableBefore); + Assert.False(manualRecoveryBefore); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + outcome); + Assert.True(lease.IsReleased); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(1, _port.Owner.ReleaseCalls); + } + + /// + /// A release status this Client version does not recognize keeps the lease active, but CheatEngine.SDK consumed + /// the disable information and set its own flag: the lease requires manual recovery, and a later release reports + /// the recorded status again without another disable. + /// + [Fact] + public void AnUnrecognizedReleaseStatusKeepsTheLeaseActiveButRequiresManualRecovery() + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = client.ApplyPatch(new AutoAssemblerScript(Script), Token); + _port.Owner!.ReleaseStatus = (TargetReleaseStatus) 99; + + LeaseReleaseOutcome first = lease.Release(); + LeaseReleaseOutcome second = lease.Release(); + + Assert.True(first.IsRetryable); + Assert.False(first.RequiresManualRecovery); + Assert.Equal(first, second); + Assert.False(lease.IsReleased); + Assert.False(lease.CanDisable); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(1, _port.Owner.ReleaseCalls); + } + + [Fact] + public void DisposeNeverThrowsWhenTheReleaseFaultsAndTheDisableIsNeverRetried() + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = + client.ApplyPatch(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken); + _port.Owner!.ReleaseFault = new InvalidOperationException("detached during [DISABLE]"); + + lease.Dispose(); + lease.Dispose(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + lease.LastReleaseOutcome); + Assert.True(lease.RequiresManualRecovery); + Assert.Equal(1, _port.Owner.ReleaseCalls); + } + + [Fact] + public void ADisableThatCouldNotBeginEndsTheLeaseAndIsReportedForManualRecovery() + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = + client.ApplyPatch(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken); + _port.Owner!.ReleaseStatus = TargetReleaseStatus.NotInvoked; + + LeaseReleaseOutcome first = lease.Release(); + LeaseReleaseOutcome second = lease.Release(); + Exception? report = ((IOutcomeReportingResource) lease).ReleaseForDeactivation(); + + // CheatEngine.SDK consumed the disable information: nothing is left to retry, so the lease ended. + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + first); + Assert.False(first.IsRetryable); + Assert.True(first.RequiresManualRecovery); + Assert.Equal(first, second); + Assert.Equal(first, lease.LastReleaseOutcome); + Assert.True(lease.IsReleased); + Assert.True(lease.RequiresManualRecovery); + Assert.False(lease.CanDisable); + Assert.Equal(CheatEngineFailureKind.RuntimeChanged, + Assert.IsType(report).Failure.Kind); + Assert.Equal(1, _port.Owner.ReleaseCalls); + } + + [Fact] + public void AReleaseThatCouldNotBeDispatchedKeepsTheDisableInformationForALaterRelease() + { + AutoAssemblerClient client = CreateClient(); + IAutoAssemblerPatchLease lease = + client.ApplyPatch(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken); + _invoker.Refuse = true; + + LeaseReleaseOutcome refused = lease.Release(); + _invoker.Refuse = false; + LeaseReleaseOutcome released = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + refused); + Assert.Equal(LeaseReleaseKind.Released, released.Kind); + Assert.False(lease.RequiresManualRecovery); + Assert.Equal(1, _port.Owner!.ReleaseCalls); + } + + [Fact] + public void ACheckReturnsCheatEnginesVerdictWithoutALease() + { + AutoAssemblerClient client = CreateClient(); + CancellationToken token = TestContext.Current.CancellationToken; + _port.CheckFacts = new AutoAssemblerCheckFacts(AutoAssemblerCheckOutcomeKind.Accepted, LuaStatus.Ok, null, false); + + AutoAssemblerCheckResult accepted = client.Check(new AutoAssemblerScript(Script), token); + _port.CheckFacts = new AutoAssemblerCheckFacts(AutoAssemblerCheckOutcomeKind.Rejected, LuaStatus.Ok, + "Unknown instruction", true); + bool checkedScript = client.TryCheck(new AutoAssemblerScript(Script), out AutoAssemblerCheckResult rejected, + out CheatEngineFailure failure, token); + + Assert.Equal(new AutoAssemblerCheckResult(true, null, false), accepted); + Assert.True(checkedScript); + Assert.Equal(default, failure); + Assert.False(rejected.IsAccepted); + Assert.Equal("Unknown instruction", rejected.HostMessages); + Assert.True(rejected.HostMessagesTruncated); + Assert.Equal("Rejected", rejected.ToString()); + Assert.Equal(2, _port.CheckCalls); + Assert.Equal(0, _port.ApplyCalls); + Assert.Same(AutoAssemblerClient.Options, _port.LastOptions); + } + + [Theory] + [InlineData(AutoAssemblerCheckOutcomeKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.NotStarted)] + [InlineData(AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure, CheatEngineFailureKind.LuaError, + CheatEngineHostEffect.Unknown)] + [InlineData(AutoAssemblerCheckOutcomeKind.InvalidResult, CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.Unknown)] + [InlineData(AutoAssemblerCheckOutcomeKind.Unknown, CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.Unknown)] + public void ACheckWithoutAVerdictIsAFailure(AutoAssemblerCheckOutcomeKind kind, + CheatEngineFailureKind expectedKind, CheatEngineHostEffect expectedEffect) + { + AutoAssemblerClient client = CreateClient(); + _port.CheckFacts = new AutoAssemblerCheckFacts(kind, LuaStatus.RuntimeError, null, false); + + bool checkedScript = client.TryCheck(new AutoAssemblerScript(Script), out AutoAssemblerCheckResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(checkedScript); + Assert.Equal(default, result); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.Equal(AutoAssemblerClient.CheckOperation, failure.Operation); + Assert.Equal(expectedKind, Assert.Throws(() => + client.Check(new AutoAssemblerScript(Script), TestContext.Current.CancellationToken)).Failure.Kind); + } + + private static void AssertPolicyRefusal(CheatEngineFailure failure, string operation) + { + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(operation, failure.Operation); + Assert.Contains("EnableAutoAssemblerPatches()", failure.Message, StringComparison.Ordinal); + } + + /// Builds the facts CheatEngine.SDK reports, with its documented effect rule for each kind. + private static AutoAssemblerApplyFacts Facts(AutoAssemblerApplyOutcomeKind kind, string? hostText = null, + bool hostTextTruncated = false, string? warnings = null, bool warningsTruncated = false, + TargetReleaseStatus? compensation = null) + { + EngineEffectState effect = kind switch + { + AutoAssemblerApplyOutcomeKind.Applied => EngineEffectState.Applied, + AutoAssemblerApplyOutcomeKind.GlobalUnavailable or AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable => + EngineEffectState.NotStarted, + _ => EngineEffectState.Unknown + }; + LuaStatus status = kind == AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure + ? LuaStatus.RuntimeError + : LuaStatus.Ok; + return new AutoAssemblerApplyFacts(kind, effect, status, hostText, hostTextTruncated, warnings, + warningsTruncated, compensation); + } + + private AutoAssemblerClient CreateClient(bool enabled = true) + { + return new AutoAssemblerClient(_dispatcher, new CoreClientPolicy([], false, enabled), _lifetime, _processes, + _port); + } + + /// Runs callbacks inline and marks the time spent inside them as Cheat Engine's main thread. + private sealed class TrackingInvoker : IMainThreadInvoker + { + private int _depth; + + internal int Calls + { + get; + private set; + } + + internal bool IsInvoking => _depth > 0; + + /// Gets or sets whether the invoker refuses the work without running it, like a closed dispatch. + internal bool Refuse + { + get; + set; + } + + /// Gets or sets work that runs once the dispatch was admitted, before the callback. + internal Action? BeforeCallback + { + get; + set; + } + + public Exception? Invoke(Action callback) + { + return Invoke(() => + { + callback(); + return true; + }).Exception; + } + + public MainThreadInvocationResult Invoke(Func callback) + { + Calls++; + if (Refuse) + { + return new MainThreadInvocationResult(default!, + new InvalidOperationException("Cheat Engine's main thread refused the work.")); + } + + _depth++; + try + { + BeforeCallback?.Invoke(); + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + finally + { + _depth--; + } + } + } + + /// A scripted Auto Assembler port; it publishes for an applied outcome. + private sealed class FakePort(TrackingInvoker invoker) : IAutoAssemblerPort + { + internal CheatEngineFailure? Admission + { + get; + set; + } + + internal int ApplyCalls + { + get; + private set; + } + + internal AutoAssemblerApplyFacts ApplyFacts + { + get; + set; + } = Facts(AutoAssemblerApplyOutcomeKind.Applied); + + internal int CheckCalls + { + get; + private set; + } + + internal AutoAssemblerCheckFacts CheckFacts + { + get; + set; + } + + internal Action? DuringApply + { + get; + set; + } + + internal Exception? Fault + { + get; + set; + } + + internal AutoAssemblerOptions? LastOptions + { + get; + private set; + } + + internal FakeOwner? Owner + { + get; + set; + } = new(invoker); + + internal bool RanOnMainThread + { + get; + private set; + } + + internal List Scripts + { + get; + } = []; + + public bool TryApply(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerApplyFacts facts, out IAutoAssemblerPatchOwner? patch, + out CheatEngineFailure admissionFailure) + { + ApplyCalls++; + Record(script, options); + facts = default; + patch = null; + if (Fault is { } fault) + { + throw fault; + } + + if (Admission is { } refusal) + { + admissionFailure = refusal; + return false; + } + + DuringApply?.Invoke(); + facts = ApplyFacts; + patch = Owner; + admissionFailure = default; + return true; + } + + public bool TryCheck(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerCheckFacts facts, out CheatEngineFailure admissionFailure) + { + CheckCalls++; + Record(script, options); + facts = default; + if (Admission is { } refusal) + { + admissionFailure = refusal; + return false; + } + + facts = CheckFacts; + admissionFailure = default; + return true; + } + + private void Record(string script, AutoAssemblerOptions options) + { + Scripts.Add(script); + LastOptions = options; + RanOnMainThread = invoker.IsInvoking; + } + } + + /// + /// Consumes its disable information on the first release, like CheatEngine.SDK's patch owner, which sets its + /// manual-recovery flag only there. + /// + private sealed class FakeOwner(TrackingInvoker invoker) : IAutoAssemblerPatchOwner + { + public TargetProcessIncarnation TargetIncarnation + { + get; + init; + } = FakeSelectedTarget.FirstIncarnation; + + public bool IsEnabled => !IsConsumed && !LostDisableInformation; + + public bool IsConsumed + { + get; + private set; + } + + public bool RequiresManualRecovery + { + get; + private set; + } + + /// Gets or sets whether Cheat Engine's Lua state was detached or replaced since the activation. + internal bool LostDisableInformation + { + get; + set; + } + + public TargetReleaseStatus LastReleaseStatus + { + get; + private set; + } + + internal int ReleaseCalls + { + get; + private set; + } + + internal List ReleasedOnMainThread + { + get; + } = []; + + internal Exception? ReleaseFault + { + get; + set; + } + + internal TargetReleaseStatus ReleaseStatus + { + get; + set; + } = TargetReleaseStatus.Released; + + public TargetReleaseStatus Release() + { + ReleaseCalls++; + ReleasedOnMainThread.Add(invoker.IsInvoking); + IsConsumed = true; + if (ReleaseFault is { } fault) + { + RequiresManualRecovery = true; + throw fault; + } + + LastReleaseStatus = ReleaseStatus; + RequiresManualRecovery = ReleaseStatus != TargetReleaseStatus.Released; + return ReleaseStatus; + } + } + + /// A binder that keys every owner to an epoch the selection already left. + private sealed class StaleSelectionBinder : ITargetSelectionBinder + { + public TargetSelectionBinding BindOwner(TargetProcessIncarnation incarnation, string operation) + { + return new TargetSelectionBinding(-1, null); + } + + public void ReportBinding(TargetSelectionBinding binding, string operation) + { + } + } + + /// Records the capability refusals, the lease releases and the Auto Assembler warnings. + private sealed class RecordingDiagnostics : ICoreDiagnostics + { + internal TrackingInvoker? Invoker + { + get; + set; + } + + internal List<(string Capability, string Operation)> Refusals + { + get; + } = []; + + internal List<(string Operation, long SelectionEpoch, bool InsideCallback)> TargetChangeWarnings + { + get; + } = []; + + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch) + { + TargetChangeWarnings.Add((operation, selectionEpoch, Invoker?.IsInvoking == true)); + } + + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState) + { + Assert.Equal(ClientCapabilityEvidenceReasonCode.Policy, gate); + Assert.Equal(ClientCapabilityEvidenceState.Missing, gateState); + Refusals.Add((capability, operation)); + } + + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch) + { + } + + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason) + { + } + + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes) + { + } + + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState) + { + } + + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration) + { + } + + public void StaleRecordIdentifierRefused(string operation, long tableGeneration) + { + } + + public void RecordActivationNotApplied(string operation, bool requestedState, string status) + { + } + + public void SymbolRegistrationRejected(string operation, string reason) + { + } + + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, + bool truncated, long hostScanMilliseconds, long copyMilliseconds) + { + } + + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength) + { + } + + public void CoreResourceCleanupFailed(string componentType, string exceptionType) + { + } + + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/AutoAssemblerMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/AutoAssemblerMappingTests.cs new file mode 100644 index 0000000..8d50c90 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/AutoAssemblerMappingTests.cs @@ -0,0 +1,179 @@ +#pragma warning disable CECLIENT5004 // The mapping produces the experimental Auto Assembler check result. + +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// Every CheatEngine.SDK 2.0.0 Auto Assembler outcome category has its Client result, and a category the SDK could +/// add later fails closed (plan L17). +/// +public sealed class AutoAssemblerMappingTests +{ + private const string Operation = "AutoAssembler.ApplyPatch"; + + /// The Client failure of each activation category; for a returned lease. + private static readonly Dictionary + ApplyResults = new() + { + [AutoAssemblerApplyOutcomeKind.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + [AutoAssemblerApplyOutcomeKind.Applied] = null, + [AutoAssemblerApplyOutcomeKind.AppliedTargetChanged] = null, + [AutoAssemblerApplyOutcomeKind.Rejected] = + (CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.Unknown), + [AutoAssemblerApplyOutcomeKind.GlobalUnavailable] = + (CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted), + [AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure] = + (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Unknown), + [AutoAssemblerApplyOutcomeKind.InvalidResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Unknown), + [AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable] = + (CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.NotStarted), + [AutoAssemblerApplyOutcomeKind.HandoffFailed] = + (CheatEngineFailureKind.BindingError, CheatEngineHostEffect.CleanupUnconfirmed) + }; + + /// The Client result of each check category: a verdict, or the failure kind of a check without one. + private static readonly Dictionary + CheckResults = new() + { + [AutoAssemblerCheckOutcomeKind.Unknown] = (null, CheatEngineFailureKind.IndeterminateHostResult), + [AutoAssemblerCheckOutcomeKind.Accepted] = (true, CheatEngineFailureKind.Unknown), + [AutoAssemblerCheckOutcomeKind.Rejected] = (false, CheatEngineFailureKind.Unknown), + [AutoAssemblerCheckOutcomeKind.GlobalUnavailable] = (null, CheatEngineFailureKind.CapabilityUnavailable), + [AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure] = (null, CheatEngineFailureKind.LuaError), + [AutoAssemblerCheckOutcomeKind.InvalidResult] = (null, CheatEngineFailureKind.InvalidHostResult) + }; + + /// The Client outcome of each release status of a patch owner. + private static readonly Dictionary ReleaseOutcomes = new() + { + [TargetReleaseStatus.Unspecified] = new(LeaseReleaseKind.Unknown, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.Released] = new(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + [TargetReleaseStatus.RefusedNoTarget] = + new(LeaseReleaseKind.RefusedTargetNotAttached, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedIdentityUnavailable] = + new(LeaseReleaseKind.RefusedTargetIdentityUnavailable, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedTargetChanged] = + new(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedProcessReused] = + new(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.UnconfirmedAfterInvocation] = + new(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + // The owner consumed its disable information although the disable could not begin: nothing is left to retry. + [TargetReleaseStatus.NotInvoked] = new(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedRuntimeChanged] = + new(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted) + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryReleaseStatusOfAPatchOwnerMapsToItsOutcome() + { + MappingTotality.AssertTotal( + static status => ReleaseOutcomes.TryGetValue(status, out LeaseReleaseOutcome expected) && + AutoAssemblerMapping.ToReleaseOutcome(status) == expected, + static status => AutoAssemblerMapping.ToReleaseOutcome(status) == + new LeaseReleaseOutcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown)); + } + + [Fact] + public void EveryStatusOfAConsumedPatchOwnerEndsTheLease() + { + // CheatEngine.SDK's own TargetReleaseOutcome.RequiresManualRecovery holds for every status of a consumed owner + // except Released; none of them leaves anything to retry. + foreach (TargetReleaseStatus status in Enum.GetValues() + .Where(static status => status != TargetReleaseStatus.Unspecified)) + { + LeaseReleaseOutcome outcome = AutoAssemblerMapping.ToReleaseOutcome(status); + + Assert.False(outcome.IsRetryable, $"{status} is retryable."); + Assert.Equal(status != TargetReleaseStatus.Released, outcome.RequiresManualRecovery); + } + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryActivationCategoryMapsToALeaseOrItsFailure() + { + MappingTotality.AssertTotal( + static kind => ApplyResults.TryGetValue(kind, out (CheatEngineFailureKind, CheatEngineHostEffect)? expected) && + Describe(AutoAssemblerMapping.ToApplyFailure(Operation, Facts(kind))) == expected, + static kind => Describe(AutoAssemblerMapping.ToApplyFailure(Operation, Facts(kind))) == + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryCheckCategoryMapsToAVerdictOrItsFailure() + { + MappingTotality.AssertTotal( + static kind => CheckResults.TryGetValue(kind, out (bool? Accepted, CheatEngineFailureKind Kind) expected) && + Check(kind) == expected, + static kind => Check(kind) == (default(bool?), CheatEngineFailureKind.IndeterminateHostResult)); + } + + [Fact] + public void TheActivationEffectComesFromTheSdkEffectStateExceptForAFailedHandoff() + { + AutoAssemblerApplyFacts rejectedNotApplied = Facts(AutoAssemblerApplyOutcomeKind.Rejected) with + { + Effect = EngineEffectState.NotApplied + }; + AutoAssemblerApplyFacts handoffNotStarted = Facts(AutoAssemblerApplyOutcomeKind.HandoffFailed) with + { + Effect = EngineEffectState.NotStarted + }; + + Assert.Equal(CheatEngineHostEffect.NotApplied, + AutoAssemblerMapping.ToApplyFailure(Operation, rejectedNotApplied)!.Value.HostEffect); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, + AutoAssemblerMapping.ToApplyFailure(Operation, handoffNotStarted)!.Value.HostEffect); + Assert.Contains("an unreported status", + AutoAssemblerMapping.ToApplyFailure(Operation, handoffNotStarted)!.Value.Message, StringComparison.Ordinal); + } + + [Fact] + public void ARejectedCheckKeepsTruncationOnlyWithItsText() + { + Assert.True(AutoAssemblerMapping.TryMapCheck(Operation, + new AutoAssemblerCheckFacts(AutoAssemblerCheckOutcomeKind.Rejected, LuaStatus.Ok, null, true), + out AutoAssemblerCheckResult withoutText, out _)); + + Assert.Equal(new AutoAssemblerCheckResult(false, null, false), withoutText); + } + + private static (CheatEngineFailureKind, CheatEngineHostEffect)? Describe(CheatEngineFailure? failure) + { + return failure is { } value ? (value.Kind, value.HostEffect) : null; + } + + private static (bool? Accepted, CheatEngineFailureKind Kind) Check(AutoAssemblerCheckOutcomeKind kind) + { + return AutoAssemblerMapping.TryMapCheck(Operation, + new AutoAssemblerCheckFacts(kind, LuaStatus.Ok, null, false), out AutoAssemblerCheckResult result, + out CheatEngineFailure failure) + ? (result.IsAccepted, CheatEngineFailureKind.Unknown) + : (null, failure.Kind); + } + + private static AutoAssemblerApplyFacts Facts(AutoAssemblerApplyOutcomeKind kind) + { + EngineEffectState effect = kind switch + { + AutoAssemblerApplyOutcomeKind.Applied => EngineEffectState.Applied, + AutoAssemblerApplyOutcomeKind.GlobalUnavailable or AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable => + EngineEffectState.NotStarted, + _ => EngineEffectState.Unknown + }; + return new AutoAssemblerApplyFacts(kind, effect, LuaStatus.Ok, null, false, null, false, null); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ClientCapabilityCatalogTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ClientCapabilityCatalogTests.cs new file mode 100644 index 0000000..b9d8b46 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ClientCapabilityCatalogTests.cs @@ -0,0 +1,146 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Tests.LiveQualification; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed partial class ClientCapabilityCatalogTests +{ + /// The scenarios each capability's qualification gate requires (plan L7, table of commit 4). + private static readonly Dictionary ExpectedScenarios = new(StringComparer.Ordinal) + { + [ClientCapabilityId.ProcessSelection.Value] = ["Q30.a", "Q31", "Q32"], + [ClientCapabilityId.TypedMemory.Value] = ["Q20", "Q21", "Q33"], + [ClientCapabilityId.PatternScanning.Value] = ["Q27", "Q28", "Q29"], + [ClientCapabilityId.ValueScanning.Value] = ["Q25", "Q26"], + [ClientCapabilityId.Inspection.Value] = ["Q16.b", "Q28"], + [ClientCapabilityId.Tables.Value] = ["Q34"], + [ClientCapabilityId.ProtectedLua.Value] = ["Q05", "Q16", "Q19"], + [ClientCapabilityId.UnsafeLuaExecution.Value] = [], + [ClientCapabilityId.Allocations.Value] = ["Q30.a"], + [ClientCapabilityId.Assembly.Value] = ["Q32"], + [ClientCapabilityId.AutoAssemblerPatches.Value] = ["Q35", "Q44"] + }; + + [Fact] + public void EveryClientCapabilityAppearsExactlyOnce() + { + string[] declared = + [ + .. typeof(ClientCapabilityId).GetProperties(BindingFlags.Public | BindingFlags.Static) + .Where(static property => property.PropertyType == typeof(ClientCapabilityId)) + .Select(static property => ((ClientCapabilityId) property.GetValue(null)!).Value) + ]; + string[] catalogued = [.. ClientCapabilityCatalog.Entries.Select(static entry => entry.Id.Value)]; + + Assert.NotEmpty(declared); + Assert.Equal(catalogued.Length, catalogued.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(declared.Order(StringComparer.Ordinal), catalogued.Order(StringComparer.Ordinal)); + } + + [Fact] + [Trait("Qualification", "Q44")] + public void EachCapabilityRequiresItsDocumentedScenarios() + { + Assert.Equal(ExpectedScenarios.Count, ClientCapabilityCatalog.Entries.Length); + foreach (ClientCapabilityDescriptor entry in ClientCapabilityCatalog.Entries) + { + Assert.True(ExpectedScenarios.TryGetValue(entry.Id.Value, out string[]? expected), entry.Id.Value); + Assert.Equal(expected, entry.RequiredScenarios); + } + } + + [Fact] + public void ScenarioIdentifiersAreWellFormedAndDistinctWithinARow() + { + foreach (ClientCapabilityDescriptor entry in ClientCapabilityCatalog.Entries) + { + Assert.False(entry.RequiredScenarios.IsDefault, entry.Id.Value); + Assert.All(entry.RequiredScenarios, static scenario => Assert.Matches(ScenarioId(), scenario)); + Assert.Equal(entry.RequiredScenarios.Length, + entry.RequiredScenarios.Distinct(StringComparer.Ordinal).Count()); + } + } + + [Fact] + public void EveryRequiredScenarioExistsInTheLiveScenarioCatalog() + { + string[] missing = + [ + .. ClientCapabilityCatalog.Entries.SelectMany(static entry => entry.RequiredScenarios) + .Where(static scenario => !ScenarioCatalog.Contains(scenario)) + ]; + + Assert.True(missing.Length == 0, + $"The live scenario catalog (CheatEngine.Client.Tests/LiveQualification) has no scenario {string.Join(", ", missing)}."); + } + + [Fact] + public void TheLiveRunnerMapsEveryCapabilityToTheCatalogScenarios() + { + Assert.Equal(ClientCapabilityCatalog.Entries.Length, ScenarioCatalog.CapabilityScenarios.Count); + foreach (ClientCapabilityDescriptor entry in ClientCapabilityCatalog.Entries) + { + Assert.True(ScenarioCatalog.CapabilityScenarios.TryGetValue(entry.Id.Value, out IReadOnlyList? scenarios), + entry.Id.Value); + Assert.Equal(entry.RequiredScenarios, scenarios); + } + } + + [Fact] + public void UnsafeLuaExecutionIsNeverQualifiedAndFollowsItsOwnOptIn() + { + ClientCapabilityDescriptor unsafeLua = Assert.Single(ClientCapabilityCatalog.Entries, + static entry => entry.Policy == CapabilityPolicySource.UnsafeLuaExecutionOptIn); + + Assert.Equal(ClientCapabilityId.UnsafeLuaExecution, unsafeLua.Id); + Assert.Empty(unsafeLua.RequiredScenarios); + Assert.All( + ClientCapabilityCatalog.Entries.Where(static entry => entry.Id != ClientCapabilityId.UnsafeLuaExecution), + static entry => Assert.NotEmpty(entry.RequiredScenarios)); + } + + [Fact] + [Trait("Qualification", "Q44")] + public void AutoAssemblerPatchesAreOperationalBehindTheirOwnOptInAndTheOnlyOtherPolicyGate() + { + ClientCapabilityDescriptor patches = Assert.Single(ClientCapabilityCatalog.Entries, + static entry => entry.Policy == CapabilityPolicySource.AutoAssemblerPatchesOptIn); + + Assert.Equal(ClientCapabilityId.AutoAssemblerPatches, patches.Id); + Assert.Equal(CapabilityHostSource.NotProbed, patches.Host); + Assert.Equal(["Q35", "Q44"], patches.RequiredScenarios); + Assert.Equal( + [ClientCapabilityId.UnsafeLuaExecution, ClientCapabilityId.AutoAssemblerPatches], + ClientCapabilityCatalog.Entries.Where(static entry => entry.Policy != CapabilityPolicySource.NotRequired) + .Select(static entry => entry.Id)); + } + + [Fact] + public void OnlyProcessSelectionTakesItsHostGateFromTheSdkSelectedProcessObservation() + { + ClientCapabilityDescriptor probed = Assert.Single(ClientCapabilityCatalog.Entries, + static entry => entry.Host == CapabilityHostSource.SdkSelectedProcess); + + Assert.Equal(ClientCapabilityId.ProcessSelection, probed.Id); + } + + [Fact] + public void ExperimentalCapabilitiesCarryTheirDiagnosticIds() + { + Assert.Equal( + [ + (ClientCapabilityId.ValueScanning, "CECLIENT5001"), (ClientCapabilityId.Allocations, "CECLIENT5002"), + (ClientCapabilityId.Assembly, "CECLIENT5003"), (ClientCapabilityId.AutoAssemblerPatches, "CECLIENT5004") + ], + ClientCapabilityCatalog.Entries + .Where(static entry => entry.ExperimentalDiagnosticId is not null) + .Select(static entry => (entry.Id, entry.ExperimentalDiagnosticId!))); + } + + [GeneratedRegex(@"^Q\d{2}(\.[a-z])?$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ScenarioId(); +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs deleted file mode 100644 index 545b70d..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs +++ /dev/null @@ -1,286 +0,0 @@ -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Core.Tests.Domains.Events; - -public sealed class BoundedEventStreamTests -{ - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void ConstructorRejectsNonPositiveCapacity(int capacity) - { - Assert.Throws(() => - { - _ = new BoundedEventStream(new EventStreamOptions(capacity)); - }); - } - - [Fact] - public void ConstructorDefensivelyRejectsDefaultOptionsWhoseCapacityIsZero() - { - Assert.Throws(() => - { - _ = new BoundedEventStream(default); - }); - } - - [Fact] - public async Task DropOldestEvictsTheOldestCopiedEventAndRecordsTheLoss() - { - using BoundedEventStream stream = new(new EventStreamOptions(2)); - - Assert.True(stream.TryPublish(1)); - Assert.True(stream.TryPublish(2)); - Assert.True(stream.TryPublish(3)); - stream.Complete(); - - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(await enumerator.MoveNextAsync()); - Assert.Equal(2, enumerator.Current); - Assert.True(await enumerator.MoveNextAsync()); - Assert.Equal(3, enumerator.Current); - Assert.False(await enumerator.MoveNextAsync()); - Assert.Equal(1, stream.LostCount); - } - - [Fact] - public async Task DropNewestPreservesTheBufferedEventsAndRecordsTheLoss() - { - using BoundedEventStream stream = new(new EventStreamOptions(2, EventStreamOverflowPolicy.DropNewest)); - - Assert.True(stream.TryPublish(1)); - Assert.True(stream.TryPublish(2)); - Assert.True(stream.TryPublish(3)); - stream.Complete(); - - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(await enumerator.MoveNextAsync()); - Assert.Equal(1, enumerator.Current); - Assert.True(await enumerator.MoveNextAsync()); - Assert.Equal(2, enumerator.Current); - Assert.False(await enumerator.MoveNextAsync()); - Assert.Equal(1, stream.LostCount); - } - - [Fact] - public async Task FailSubscriptionClosesTheStreamWithAnErrorAndRejectsFutureAdmission() - { - using BoundedEventStream stream = - new(new EventStreamOptions(1, EventStreamOverflowPolicy.FailSubscription)); - - Assert.True(stream.TryPublish(1)); - Assert.False(stream.TryPublish(2)); - - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - InvalidOperationException exception = await Assert.ThrowsAsync(async () => - { - _ = await enumerator.MoveNextAsync(); - }); - - Assert.Contains("overflowed", exception.Message, StringComparison.Ordinal); - Assert.True(stream.IsCompleted); - Assert.False(stream.IsAdmissionOpen); - Assert.Equal(2, stream.LostCount); - Assert.False(stream.TryPublish(3)); - } - - [Fact] - public async Task PendingReaderReceivesPublishedValueWithoutBlockingTheCallbackAdmissionPath() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - await using IAsyncEnumerator enumerator = - stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - - Task moveNext = enumerator.MoveNextAsync().AsTask(); - Assert.False(moveNext.IsCompleted); - - Assert.True(stream.TryPublish("copied")); - Assert.True(await moveNext); - Assert.Equal("copied", enumerator.Current); - } - - [Fact] - public async Task ConcurrentReadersAreRejectedUntilTheActiveReaderIsDisposed() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - IAsyncEnumerator first = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - - for (int reader = 0; reader < 64; reader++) - { - InvalidOperationException exception = Assert.Throws(() => - { - _ = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - }); - Assert.Contains("one active", exception.Message, StringComparison.Ordinal); - } - - await first.DisposeAsync(); - await using IAsyncEnumerator second = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(stream.TryPublish(10)); - Assert.True(await second.MoveNextAsync()); - Assert.Equal(10, second.Current); - } - - [Fact] - public async Task CompletionClosesAdmissionAndCompletesAWaitingReader() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - - Task moveNext = enumerator.MoveNextAsync().AsTask(); - Assert.False(moveNext.IsCompleted); - - stream.Complete(); - - Assert.False(await moveNext); - Assert.True(stream.IsCompleted); - Assert.False(stream.IsAdmissionOpen); - Assert.False(stream.TryPublish(1)); - } - - [Fact] - public async Task CancellationRemovesTheWaitingReaderWithoutDiscardingTheNextPublishedEvent() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - using CancellationTokenSource cancellation = new(); - await using IAsyncEnumerator cancelledEnumerator = stream.GetAsyncEnumerator(cancellation.Token); - - Task cancelledMoveNext = cancelledEnumerator.MoveNextAsync().AsTask(); - cancellation.Cancel(); - await Assert.ThrowsAnyAsync(async () => - { - _ = await cancelledMoveNext; - }); - - Assert.True(stream.TryPublish(7)); - await using IAsyncEnumerator activeEnumerator = - stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(await activeEnumerator.MoveNextAsync()); - Assert.Equal(7, activeEnumerator.Current); - Assert.Equal(0, stream.LostCount); - } - - [Fact] - public async Task DisposingAWaitingEnumeratorCompletesItsReadAndFreesTheReaderSlot() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - IAsyncEnumerator disposedEnumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Task pendingMoveNext = disposedEnumerator.MoveNextAsync().AsTask(); - - await disposedEnumerator.DisposeAsync(); - - Assert.False(await pendingMoveNext); - await using IAsyncEnumerator activeEnumerator = - stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(stream.TryPublish(7)); - Assert.True(await activeEnumerator.MoveNextAsync()); - Assert.Equal(7, activeEnumerator.Current); - } - - [Fact] - public async Task DisposeClosesAdmissionDiscardsBufferedValuesAndCompletesReaders() - { - BoundedEventStream stream = new(new EventStreamOptions(1)); - Assert.True(stream.TryPublish(1)); - stream.Dispose(); - - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.False(await enumerator.MoveNextAsync()); - Assert.True(stream.IsCompleted); - Assert.False(stream.IsAdmissionOpen); - Assert.False(stream.TryPublish(2)); - Assert.Equal(1, stream.LostCount); - - stream.Dispose(); - } - - [Fact] - public async Task DisposeCompletesAWaitingReaderWithoutRetainingIt() - { - BoundedEventStream stream = new(new EventStreamOptions(1)); - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Task pendingMoveNext = enumerator.MoveNextAsync().AsTask(); - - stream.Dispose(); - - Assert.False(await pendingMoveNext); - Assert.False(stream.TryPublish(1)); - } - - [Fact] - public async Task CloseAdmissionRejectsNewObservationsAndLetsAcceptedObservationsDrain() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - Assert.True(stream.TryPublish(1)); - stream.CloseAdmission(); - stream.Complete(); - - Assert.False(stream.TryPublish(2)); - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(await enumerator.MoveNextAsync()); - Assert.Equal(1, enumerator.Current); - Assert.False(await enumerator.MoveNextAsync()); - Assert.Equal(0, stream.LostCount); - } - - [Fact] - public async Task FaultedCompletionDiscardsBufferedEventsReportsLossAndFailsReaders() - { - using BoundedEventStream stream = new(new EventStreamOptions(2)); - InvalidOperationException expected = new("callback failure"); - Assert.True(stream.TryPublish(1)); - stream.Complete(expected); - - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - InvalidOperationException actual = await Assert.ThrowsAsync(async () => - { - _ = await enumerator.MoveNextAsync(); - }); - - Assert.Same(expected, actual); - Assert.Equal(1, stream.LostCount); - Assert.False(stream.TryPublish(2)); - } - - [Fact] - public async Task PublicationDoesNotWaitForASlowConsumerContinuation() - { - CancellationToken cancellationToken = TestContext.Current.CancellationToken; - using BoundedEventStream stream = new(new EventStreamOptions(1)); - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(cancellationToken); - using ManualResetEventSlim consumerMayContinue = new(false); - TaskCompletionSource consumerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); - - Task consumer = Task.Run(async () => - { - Assert.True(await enumerator.MoveNextAsync()); - consumerEntered.SetResult(); - consumerMayContinue.Wait(cancellationToken); - }, cancellationToken); - - Task publish = Task.Run(() => stream.TryPublish(1)); - await consumerEntered.Task.WaitAsync(cancellationToken); - Assert.True(publish.IsCompletedSuccessfully); - - consumerMayContinue.Set(); - await consumer; - } - - [Fact] - public async Task OneEnumeratorRejectsOverlappingMoveNextCalls() - { - using BoundedEventStream stream = new(new EventStreamOptions(1)); - await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - - Task firstMoveNext = enumerator.MoveNextAsync().AsTask(); - InvalidOperationException exception = await Assert.ThrowsAsync(async () => - { - _ = await enumerator.MoveNextAsync(); - }); - Assert.Contains("Concurrent", exception.Message, StringComparison.Ordinal); - - stream.Complete(); - Assert.False(await firstMoveNext); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs deleted file mode 100644 index afc4905..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs +++ /dev/null @@ -1,195 +0,0 @@ -using CheatEngine.Client.Core.Domains.Events; -using CheatEngine.Client.Events; - -namespace CheatEngine.Client.Core.Tests.Domains.Events; - -public sealed class EventStreamLeaseTests -{ - [Fact] - public async Task DisposeStopsAdmissionBeforeNeutralizingThenCompletesAndReleasesInOrder() - { - List calls = []; - BoundedEventStream stream = new(new EventStreamOptions(1)); - EventStreamLease lease = new( - stream, - () => - { - Assert.False(stream.IsAdmissionOpen); - calls.Add("neutralize"); - }, - () => calls.Add("release"), - _ => calls.Add("untrack")); - - lease.Dispose(); - - Assert.True(lease.IsReleased); - Assert.Equal(["neutralize", "release", "untrack"], calls); - Assert.False(lease.TryPublish(1)); - await using IAsyncEnumerator enumerator = - lease.Events.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.False(await enumerator.MoveNextAsync()); - } - - [Fact] - public async Task DisposeCompletesAWaitingReaderAndClosesAdmissionBeforeReleasingTheHostRegistration() - { - BoundedEventStream stream = new(new EventStreamOptions(1)); - await using IAsyncEnumerator enumerator = - stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Task pendingMoveNext = enumerator.MoveNextAsync().AsTask(); - EventStreamLease lease = new(stream, static () => - { - }, () => Assert.False(stream.IsAdmissionOpen)); - - lease.Dispose(); - - Assert.False(await pendingMoveNext); - Assert.True(lease.IsReleased); - Assert.False(lease.TryPublish(1)); - } - - [Fact] - public void DisposeIsIdempotentAndDoesNotReleaseTheHostTwice() - { - int neutralized = 0; - int released = 0; - BoundedEventStream stream = new(new EventStreamOptions(1)); - EventStreamLease lease = new(stream, () => neutralized++, () => released++); - - lease.Dispose(); - lease.Dispose(); - - Assert.Equal(1, neutralized); - Assert.Equal(1, released); - } - - [Fact(Timeout = 10_000)] - public async Task DisposeDoesNotHoldItsGateWhileExternalTeardownWaitsForReentrantCallbacks() - { - CancellationToken cancellationToken = TestContext.Current.CancellationToken; - TaskCompletionSource neutralizationStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource neutralizationCallbackStopped = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource releaseStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource releaseCallbackStopped = new(TaskCreationOptions.RunContinuationsAsynchronously); - List calls = []; - BoundedEventStream stream = new(new EventStreamOptions(1)); - EventStreamLease lease = null!; - - Task callback = Task.Run(async () => - { - await neutralizationStarted.Task.WaitAsync(cancellationToken); - lease.Dispose(); - neutralizationCallbackStopped.SetResult(); - - await releaseStarted.Task.WaitAsync(cancellationToken); - lease.Dispose(); - releaseCallbackStopped.SetResult(); - }, cancellationToken); - - lease = new EventStreamLease( - stream, - () => - { - calls.Add("neutralize"); - neutralizationStarted.SetResult(); - neutralizationCallbackStopped.Task.Wait(cancellationToken); - }, - () => - { - calls.Add("release"); - releaseStarted.SetResult(); - releaseCallbackStopped.Task.Wait(cancellationToken); - }, - _ => calls.Add("untrack")); - - await Task.Run(lease.Dispose, cancellationToken).WaitAsync(cancellationToken); - await callback.WaitAsync(cancellationToken); - - Assert.True(lease.IsReleased); - Assert.True(stream.IsCompleted); - Assert.Equal(["neutralize", "release", "untrack"], calls); - } - - [Fact(Timeout = 10_000)] - public async Task ConcurrentDisposalsRunOnlyOneCleanupSequence() - { - CancellationToken cancellationToken = TestContext.Current.CancellationToken; - using ManualResetEventSlim allowNeutralizationToFinish = new(false); - TaskCompletionSource neutralizationStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); - int neutralized = 0; - int released = 0; - int untracked = 0; - BoundedEventStream stream = new(new EventStreamOptions(1)); - EventStreamLease lease = new( - stream, - () => - { - Interlocked.Increment(ref neutralized); - neutralizationStarted.SetResult(); - allowNeutralizationToFinish.Wait(); - }, - () => Interlocked.Increment(ref released), - _ => Interlocked.Increment(ref untracked)); - - Task firstDispose = Task.Run(lease.Dispose, cancellationToken); - await neutralizationStarted.Task.WaitAsync(cancellationToken); - Task secondDispose = Task.Run(lease.Dispose, cancellationToken); - - try - { - await secondDispose.WaitAsync(cancellationToken); - Assert.False(firstDispose.IsCompleted); - } - finally - { - allowNeutralizationToFinish.Set(); - } - - await firstDispose.WaitAsync(cancellationToken); - - Assert.True(lease.IsReleased); - Assert.Equal(1, neutralized); - Assert.Equal(1, released); - Assert.Equal(1, untracked); - } - - [Fact] - public async Task DisposeCompletesTheStreamAndReleasesTheHostEvenWhenNeutralizationFails() - { - BoundedEventStream stream = new(new EventStreamOptions(1)); - int released = 0; - EventStreamLease lease = new(stream, - static () => throw new InvalidOperationException("neutralize"), - () => released++); - - InvalidOperationException exception = Assert.Throws(lease.Dispose); - - Assert.Equal("neutralize", exception.Message); - Assert.Equal(1, released); - Assert.True(lease.IsReleased); - Assert.False(lease.TryPublish(1)); - await using IAsyncEnumerator enumerator = - lease.Events.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.False(await enumerator.MoveNextAsync()); - } - - [Fact] - public async Task LeaseExposesTheStreamLossCounterWithoutWaitingForConsumers() - { - BoundedEventStream stream = new(new EventStreamOptions(1)); - using EventStreamLease lease = new(stream, static () => - { - }, static () => - { - }); - - Assert.True(lease.TryPublish(1)); - Assert.True(lease.TryPublish(2)); - Assert.Equal(1, lease.DroppedEventCount); - - await using IAsyncEnumerator enumerator = - lease.Events.GetAsyncEnumerator(TestContext.Current.CancellationToken); - Assert.True(await enumerator.MoveNextAsync()); - Assert.Equal(2, enumerator.Current); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs index c5e17be..1486bd2 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs @@ -8,6 +8,7 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Tests.Domains; @@ -18,12 +19,15 @@ public void GetModulesCopiesOnlyTheWrittenEntriesAndUsesTheRequestedProcessOverl { using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); - FakeInspectionPort port = new() { ModulesWritten = 1 }; + FakeInspectionPort port = new() + { + ModulesWritten = 1 + }; InspectionClient client = CreateClient(lifetime, port); - bool succeeded = client.TryGetModules(new InspectionCollectionRequest(2), - out ImmutableArray modules, - out CheatEngineFailure failure, new TargetProcessId(42), TestContext.Current.CancellationToken); + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(2), new TargetProcessId(42), + out ImmutableArray modules, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); Assert.True(succeeded); Assert.Equal(default, failure); @@ -40,13 +44,16 @@ public void GetModulesCopiesOnlyTheWrittenEntriesAndUsesTheRequestedProcessOverl [InlineData(InspectionStatus.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] [InlineData(InspectionStatus.LuaFailure, CheatEngineFailureKind.LuaError)] [InlineData(InspectionStatus.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] - [InlineData((InspectionStatus) 999, CheatEngineFailureKind.Unknown)] + [InlineData((InspectionStatus) 999, CheatEngineFailureKind.IndeterminateHostResult)] public void InspectionStatusesMapToStableClientFailures(InspectionStatus status, CheatEngineFailureKind expectedKind) { using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); - FakeInspectionPort port = new() { MemoryRegionStatus = status }; + FakeInspectionPort port = new() + { + MemoryRegionStatus = status + }; InspectionClient client = CreateClient(lifetime, port); bool succeeded = client.TryGetMemoryRegion(new Address(0x1234), out MemoryRegionInfo region, @@ -65,12 +72,13 @@ public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries( using CoreLifetime lifetime = new(context); FakeInspectionPort port = new() { - SectionsWritten = 1, RegionsWritten = 1, ResolvedAddress = new Address(0xC0FFEE) + SectionsWritten = 1, + RegionsWritten = 1, + ResolvedAddress = new Address(0xC0FFEE) }; InspectionClient client = CreateClient(lifetime, port); ModuleName module = new("fixture.exe"); SymbolExpression symbol = new("fixture+10"); - AddressResolutionOptions options = new(true, true); Assert.True(client.TryGetModuleSections(module, new InspectionCollectionRequest(2), out ImmutableArray sections, out CheatEngineFailure sectionsFailure, @@ -80,7 +88,7 @@ public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries( TestContext.Current.CancellationToken)); Assert.True(client.TryGetSymbol(symbol, out SymbolInfo returnedSymbol, out CheatEngineFailure symbolFailure, TestContext.Current.CancellationToken)); - Assert.True(client.TryResolveAddress(symbol, options, out Address resolved, + Assert.True(client.TryResolveAddress(symbol, AddressResolutionMode.Shallow, out Address resolved, out CheatEngineFailure addressFailure, TestContext.Current.CancellationToken)); @@ -97,27 +105,57 @@ public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries( Assert.Equal(2, port.LastRegionBufferLength); Assert.Equal(symbol, port.LastSymbolExpression); Assert.Equal(symbol, port.LastAddressExpression); - Assert.Equal(options, port.LastAddressOptions); + Assert.Equal(AddressResolutionMode.Shallow, port.LastAddressMode); + } + + [Fact] + public void AnUndefinedResolutionModeIsAProgrammingErrorThatNeverReachesCheatEngine() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() + { + ResolvedAddress = new Address(0xC0FFEE) + }; + InspectionClient client = CreateClient(lifetime, port); + SymbolExpression symbol = new("fixture+10"); + + ArgumentOutOfRangeException tryForm = Assert.Throws(() => + client.TryResolveAddress(symbol, (AddressResolutionMode) 2, out _, out _, + TestContext.Current.CancellationToken)); + ArgumentOutOfRangeException throwingForm = Assert.Throws(() => + client.ResolveAddress(symbol, (AddressResolutionMode) (-1), TestContext.Current.CancellationToken)); + + Assert.Equal("mode", tryForm.ParamName); + Assert.Equal("mode", throwingForm.ParamName); + Assert.Equal(default, port.LastAddressExpression); } [Theory] - [InlineData(true, "playerHealth", true, CheatEngineFailureKind.Unknown)] - [InlineData(false, null, false, CheatEngineFailureKind.NotFound)] - [InlineData(true, null, false, CheatEngineFailureKind.InvalidHostResult)] - public void ResolveNameDistinguishesANameFromNotFoundAndInvalidHostResults(bool resolveName, string? name, + [InlineData(LuaOperationStatusKind.Success, "playerHealth", true, CheatEngineFailureKind.Unknown)] + [InlineData(LuaOperationStatusKind.NilResult, null, false, CheatEngineFailureKind.NotFound)] + [InlineData(LuaOperationStatusKind.Success, null, false, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(LuaOperationStatusKind.GlobalUnavailable, null, false, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(LuaOperationStatusKind.LuaFailure, null, false, CheatEngineFailureKind.LuaError)] + [InlineData(LuaOperationStatusKind.Unknown, null, false, CheatEngineFailureKind.IndeterminateHostResult)] + public void ResolveNameMapsEverySdkLookupStatus(LuaOperationStatusKind status, string? name, bool expectedSuccess, CheatEngineFailureKind expectedKind) { using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); - FakeInspectionPort port = new() { ResolveNameResult = resolveName, ResolvedName = name }; + FakeInspectionPort port = new() + { + NameStatus = StatusOf(status), + ResolvedName = name + }; InspectionClient client = CreateClient(lifetime, port); bool succeeded = client.TryResolveName(new Address(0x1234), out string? result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.Equal(expectedSuccess, succeeded); - Assert.Equal(name, result); - Assert.Equal(new nuint(0x1234), port.LastNameAddress); + Assert.Equal(expectedSuccess ? name : null, result); + Assert.Equal(new Address(0x1234), port.LastNameAddress); if (expectedSuccess) { Assert.Equal(default, failure); @@ -130,6 +168,7 @@ public void ResolveNameDistinguishesANameFromNotFoundAndInvalidHostResults(bool } [Fact] + [Trait("Qualification", "Q16.b")] public void RegisterSymbolReservesOneNameAndReleasesItWithTheLease() { using ControlledCoreLifetimeContext context = new(); @@ -146,14 +185,20 @@ public void RegisterSymbolReservesOneNameAndReleasesItWithTheLease() Assert.Null(duplicate); Assert.Equal(default, firstFailure); Assert.Equal(CheatEngineFailureKind.OperationRejected, duplicateFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, duplicateFailure.HostEffect); + Assert.Contains("already owns", duplicateFailure.Message, StringComparison.Ordinal); Assert.Equal(1, port.RegisterCalls); Assert.Equal("fixture-symbol", port.LastRegisteredName); - Assert.Equal(new nuint(0x401000), port.LastRegisteredAddress); + Assert.Equal(new Address(0x401000), port.LastRegisteredAddress); Assert.True(port.LastRegisteredDoNotSave); + Assert.Equal("fixture-symbol", lease.Name); + Assert.Equal(new Address(0x401000), lease.Address); lease.Dispose(); Assert.True(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + lease.LastReleaseOutcome); Assert.Equal(["fixture-symbol"], port.UnregisteredNames); Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? retry, out CheatEngineFailure retryFailure, TestContext.Current.CancellationToken)); @@ -162,6 +207,381 @@ public void RegisterSymbolReservesOneNameAndReleasesItWithTheLease() retry.Dispose(); } + [Fact] + [Trait("Qualification", "Q16")] + public void RegisterSymbolRejectsANameThatAlreadyResolvesBeforeAnyRegistration() + { + // A14-39: registerSymbol would shadow or replace a definition that already resolves (a third-party symbol, a + // module, or an expression that parses as an address); CheatEngine.SDK keeps that behavior, so the Client checks. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + port.Symbols["thirdPartySymbol"] = new Address(0x500000); + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryRegisterSymbol(new SymbolRegistration("thirdPartySymbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Contains("already resolves", failure.Message, StringComparison.Ordinal); + Assert.Equal(0, port.RegisterCalls); + Assert.Equal(new SymbolExpression("thirdPartySymbol"), port.LastAddressExpression); + Assert.Equal(AddressResolutionMode.Default, port.LastAddressMode); + Assert.Equal(new Address(0x500000), port.Symbols["thirdPartySymbol"]); + } + + [Theory] + [InlineData(InspectionStatus.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(InspectionStatus.LuaFailure, CheatEngineFailureKind.LuaError)] + [InlineData(InspectionStatus.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] + public void RegisterSymbolRejectsWhenTheCollisionLookupFails(InspectionStatus status, + CheatEngineFailureKind expectedKind) + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() + { + ResolveStatusOverride = status + }; + InspectionClient client = CreateClient(lifetime, port); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000)); + + bool succeeded = client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Contains("ownership of the name cannot be established", failure.Message, StringComparison.Ordinal); + Assert.Equal(0, port.RegisterCalls); + + port.ResolveStatusOverride = null; + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? retry, out _, + TestContext.Current.CancellationToken), "A failed check releases the activation-local reservation."); + retry!.Dispose(); + } + + [Fact] + public void RegisterSymbolReservationIsCaseInsensitive() + { + // Cheat Engine's case rules for user symbols are not established, so the reservation is conservative. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("PlayerHealth", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out _, TestContext.Current.CancellationToken)); + bool duplicate = client.TryRegisterSymbol(new SymbolRegistration("playerhealth", new Address(0x402000)), + out ISymbolRegistrationLease? second, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(duplicate); + Assert.Null(second); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(1, port.RegisterCalls); + lease!.Dispose(); + } + + [Theory] + [Trait("Qualification", "Q16.b")] + [InlineData(LuaOperationStatusKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.Unknown)] + [InlineData(LuaOperationStatusKind.LuaFailure, CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Started)] + [InlineData(LuaOperationStatusKind.StackUnavailable, CheatEngineFailureKind.LuaError, + CheatEngineHostEffect.NotStarted)] + [InlineData(LuaOperationStatusKind.InvalidResult, CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.Started)] + [InlineData(LuaOperationStatusKind.Unknown, CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.Unknown)] + [InlineData(LuaOperationStatusKind.Success, CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.CleanupUnconfirmed)] + public void ARegistrationWithoutAnSdkLeaseOwnsNothingAndFreesTheReservation(LuaOperationStatusKind status, + CheatEngineFailureKind expectedKind, CheatEngineHostEffect expectedEffect) + { + // Success without a lease breaks the SDK acquisition contract: Cheat Engine accepted a registration that nothing + // owns. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() + { + RegistrationStatus = StatusOf(status), + OmitHandle = true + }; + InspectionClient client = CreateClient(lifetime, port); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000)); + + bool succeeded = client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.Equal("Inspection.RegisterSymbol", failure.Operation); + Assert.DoesNotContain("fixture-symbol", failure.Message, StringComparison.Ordinal); + Assert.Equal(1, port.RegisterCalls); + + port.RegistrationStatus = LuaOperationStatus.Success; + port.OmitHandle = false; + port.Symbols.Remove("fixture-symbol"); + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? retry, out _, + TestContext.Current.CancellationToken), "A registration without a lease releases the reservation."); + retry!.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q16.b")] + public void AHandoffFailureIsReportedAsAnUnconfirmedCleanupAndOwnsNothing() + { + // CheatEngine.SDK registered the name, could not publish its lease and compensated once: the Client never claims + // the registration and never retries an unregistration by name. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SymbolRegistrationHandoffException handoff = new(default, new InvalidOperationException("publication failed")); + FakeInspectionPort port = new() + { + RegistrationFault = handoff + }; + InspectionClient client = CreateClient(lifetime, port); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000)); + + bool succeeded = client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.BindingError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal("Inspection.RegisterSymbol", failure.Operation); + Assert.Same(handoff, failure.Exception); + Assert.Empty(port.UnregisteredNames); + CheatEngineOperationException thrown = Assert.Throws(() => + client.RegisterSymbol(registration, TestContext.Current.CancellationToken)); + Assert.Same(handoff, thrown.Failure.Exception); + + port.RegistrationFault = null; + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? retry, out _, + TestContext.Current.CancellationToken), "A handoff failure releases the activation-local reservation."); + retry!.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q16.b")] + public void LeaseUnregistersWhenTheNameStillMapsToTheLeasedAddress() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out _, TestContext.Current.CancellationToken)); + + LeaseReleaseOutcome outcome = lease.Release(); + LeaseReleaseOutcome repeated = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), outcome); + Assert.Equal(outcome, repeated); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + Assert.Equal(1, port.ReleaseCalls); + Assert.True(lease.IsReleased); + } + + [Fact] + [Trait("Qualification", "Q16.b")] + public void LeaseSkipsUnregisterWhenTheNameWasReplacedByAThirdParty() + { + // A14-25: a name that a third party re-registered at another address is never removed by the Client. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out _, TestContext.Current.CancellationToken)); + port.Symbols["fixture-symbol"] = new Address(0x777000); + + LeaseReleaseOutcome outcome = lease.Release(); + lease.Dispose(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Replaced, CheatEngineHostEffect.NotStarted), outcome); + Assert.Empty(port.UnregisteredNames); + Assert.Equal(new Address(0x777000), port.Symbols["fixture-symbol"]); + Assert.True(lease.IsReleased); + } + + [Fact] + public void LeaseReportsExternallyRemovedWhenTheNameNoLongerResolves() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out _, TestContext.Current.CancellationToken)); + port.Symbols.Remove("fixture-symbol"); + + LeaseReleaseOutcome outcome = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.ExternallyRemoved, CheatEngineHostEffect.NotStarted), + outcome); + Assert.Empty(port.UnregisteredNames); + Assert.True(lease.IsReleased); + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? again, out _, TestContext.Current.CancellationToken)); + again!.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q16.b")] + public void ACoordinatorSupersededLeaseLeavesTheNewerRegistrationAndFreesTheActivationReservation() + { + // The activation-local reservation refuses a second registration of the name before CheatEngine.SDK is reached, + // so only another owner that registers the name through the same SDK coordinator can supersede the lease. The + // superseded lease sends no unregistration, and once it ended the collision pre-check, not the reservation, + // protects the newer registration. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000)); + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, out _, + TestContext.Current.CancellationToken)); + Assert.False(client.TryRegisterSymbol(registration, out _, out CheatEngineFailure reserved, + TestContext.Current.CancellationToken)); + port.RegisterThroughCoordinator("fixture-symbol", new Address(0x777000)); + + LeaseReleaseOutcome outcome = lease.Release(); + bool registeredAgain = client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? again, + out CheatEngineFailure collision, TestContext.Current.CancellationToken); + + Assert.Contains("already owns", reserved.Message, StringComparison.Ordinal); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Superseded, CheatEngineHostEffect.NotStarted), outcome); + Assert.True(outcome.IsComplete); + Assert.True(lease.IsReleased); + Assert.Empty(port.UnregisteredNames); + Assert.Equal(new Address(0x777000), port.Symbols["fixture-symbol"]); + Assert.False(registeredAgain); + Assert.Null(again); + Assert.Equal(CheatEngineFailureKind.OperationRejected, collision.Kind); + Assert.Contains("already resolves", collision.Message, StringComparison.Ordinal); + Assert.Equal(1, port.RegisterCalls); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void LeaseWhoseOwnershipCheckFailsStaysActiveUntilACleanupRetrySucceeds() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out _, TestContext.Current.CancellationToken)); + port.ResolveStatusOverride = InspectionStatus.LuaFailure; + + lease.Dispose(); + LeaseReleaseOutcome? unavailable = lease.LastReleaseOutcome; + bool releasedWhileUnavailable = lease.IsReleased; + port.ResolveStatusOverride = null; + LeaseReleaseOutcome retried = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + unavailable); + Assert.False(releasedWhileUnavailable); + Assert.Equal(LeaseReleaseKind.Released, retried.Kind); + Assert.True(lease.IsReleased); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void TheDeactivationCleanupReleasesALeaseTheApplicationKept() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + Assert.True(client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out _, TestContext.Current.CancellationToken)); + + context.Stop(); + using (lifetime.EnterCleanupScope()) + { + lifetime.DrainOwnedResourcesForDisable(); + } + + Assert.True(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + lease.LastReleaseOutcome); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void ARegistrationWhoseActivationStopsBeforeItIsOwnedIsReleasedOnceOnTheMainThread() + { + // The lease joins the activation inside the dispatched registration; when the activation began stopping in + // between, nothing would own the registration, so it is released once in the same main-thread call. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() + { + OnRegister = context.Stop + }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("Inspection.RegisterSymbol", failure.Operation); + Assert.IsType(failure.Exception); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + Assert.Equal(1, port.ReleaseCalls); + Assert.False(port.Symbols.ContainsKey("fixture-symbol")); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void ARegistrationWhoseActivationResourcesWereDrainedIsReleasedOnceOnTheMainThread() + { + // The activation is still current, but its resources were drained before the lease could join them: the + // closed registry refuses the lease with an ObjectDisposedException, so nothing would own the registration. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() + { + OnRegister = () => + { + using (lifetime.EnterCleanupScope()) + { + lifetime.DrainOwnedResourcesForDisable(); + } + } + }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), + out ISymbolRegistrationLease? lease, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("Inspection.RegisterSymbol", failure.Operation); + Assert.IsType(failure.Exception); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + Assert.Equal(1, port.ReleaseCalls); + Assert.False(port.Symbols.ContainsKey("fixture-symbol")); + } + [Fact] public void CancelledInspectionDoesNotContactTheSdkPort() { @@ -172,9 +592,8 @@ public void CancelledInspectionDoesNotContactTheSdkPort() using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - bool succeeded = client.TryGetModules(new InspectionCollectionRequest(1), - out ImmutableArray modules, - out CheatEngineFailure failure, cancellationToken: cancellation.Token); + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(1), null, + out ImmutableArray modules, out CheatEngineFailure failure, cancellation.Token); Assert.False(succeeded); Assert.Empty(modules); @@ -183,14 +602,157 @@ public void CancelledInspectionDoesNotContactTheSdkPort() Assert.Equal(0, port.ExplicitProcessModuleCalls); } + [Fact] + [Trait("Qualification", "Q43")] + public void ARegistrationWhoseActivationStopsBeforeItsCallbackRegistersNothing() + { + // The activation was active when the registration was admitted and began stopping before the dispatched + // callback ran: the callback refuses before the collision check, so Cheat Engine registers nothing. + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = new(new SdkMainThreadDispatcher(lifetime, new StoppingMainThreadInvoker(context)), + lifetime, port); + + CheatEngineInvalidStateException exception = Assert.Throws(() => + client.TryRegisterSymbol(new SymbolRegistration("fixture-symbol", new Address(0x401000)), out _, out _, + TestContext.Current.CancellationToken)); + + Assert.Equal("Inspection.RegisterSymbol", exception.Failure.Operation); + Assert.Equal(0, port.RegisterCalls); + Assert.False(port.Symbols.ContainsKey("fixture-symbol")); + } + + /// + /// A default or out-of-range argument is a programming error: the Try and the throwing forms throw what the + /// argument's constructor throws for the same value, before any dispatch, including the default module name and + /// symbol expression that CheatEngine.SDK would otherwise refuse on Cheat Engine's main thread. + /// + [Theory] + [InlineData("GetModules.DefaultRequest", "request", typeof(ArgumentOutOfRangeException))] + [InlineData("GetModules.DefaultProcessId", "processId", typeof(ArgumentOutOfRangeException))] + [InlineData("GetModuleSections.DefaultModuleName", "moduleName", typeof(ArgumentException))] + [InlineData("GetModuleSections.DefaultRequest", "request", typeof(ArgumentOutOfRangeException))] + [InlineData("GetMemoryRegions.DefaultRequest", "request", typeof(ArgumentOutOfRangeException))] + [InlineData("GetSymbol.DefaultExpression", "expression", typeof(ArgumentException))] + [InlineData("RegisterSymbol.DefaultRegistration", "registration", typeof(ArgumentException))] + [InlineData("ResolveAddress.DefaultExpression", "expression", typeof(ArgumentException))] + [InlineData("ResolveAddress.UndefinedMode", "mode", typeof(ArgumentOutOfRangeException))] + public void ADefaultOrOutOfRangeArgumentThrowsBeforeDispatch(string entryPoint, string parameter, Type expected) + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + CountingMainThreadInvoker invoker = new(); + InspectionClient client = new(new SdkMainThreadDispatcher(lifetime, invoker), lifetime, port); + CancellationToken token = TestContext.Current.CancellationToken; + InspectionCollectionRequest request = new(8); + SymbolExpression expression = new("game.exe+10"); + (Action TryForm, Action ThrowingForm) forms = entryPoint switch + { + "GetModules.DefaultRequest" => (() => client.TryGetModules(default, null, out _, out _, token), + () => client.GetModules(default, null, token)), + "GetModules.DefaultProcessId" => (() => client.TryGetModules(request, default(TargetProcessId), out _, + out _, token), () => client.GetModules(request, default(TargetProcessId), token)), + "GetModuleSections.DefaultModuleName" => ( + () => client.TryGetModuleSections(default, request, out _, out _, token), + () => client.GetModuleSections(default, request, token)), + "GetModuleSections.DefaultRequest" => ( + () => client.TryGetModuleSections(new ModuleName("game.exe"), default, out _, out _, token), + () => client.GetModuleSections(new ModuleName("game.exe"), default, token)), + "GetMemoryRegions.DefaultRequest" => (() => client.TryGetMemoryRegions(default, out _, out _, token), + () => client.GetMemoryRegions(default, token)), + "GetSymbol.DefaultExpression" => (() => client.TryGetSymbol(default, out _, out _, token), + () => client.GetSymbol(default, token)), + "RegisterSymbol.DefaultRegistration" => (() => client.TryRegisterSymbol(default, out _, out _, token), + () => client.RegisterSymbol(default, token)), + "ResolveAddress.DefaultExpression" => ( + () => client.TryResolveAddress(default, AddressResolutionMode.Default, out _, out _, token), + () => client.ResolveAddress(default, AddressResolutionMode.Default, token)), + "ResolveAddress.UndefinedMode" => ( + () => client.TryResolveAddress(expression, (AddressResolutionMode) 99, out _, out _, token), + () => client.ResolveAddress(expression, (AddressResolutionMode) 99, token)), + _ => throw new ArgumentOutOfRangeException(nameof(entryPoint), entryPoint, null) + }; + + ArgumentException tryForm = Assert.ThrowsAny(forms.TryForm); + ArgumentException throwingForm = Assert.ThrowsAny(forms.ThrowingForm); + + Assert.IsType(expected, tryForm); + Assert.IsType(expected, throwingForm); + Assert.Equal(parameter, tryForm.ParamName); + Assert.Equal(0, invoker.Invocations); + Assert.Equal(0, port.RegisterCalls); + } + private static InspectionClient CreateClient(CoreLifetime lifetime, IInspectionPort port) { return new InspectionClient(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, port); } + private static LuaOperationStatus StatusOf(LuaOperationStatusKind kind) + { + return kind switch + { + LuaOperationStatusKind.Success => LuaOperationStatus.Success, + LuaOperationStatusKind.GlobalUnavailable => LuaOperationStatus.GlobalUnavailable, + LuaOperationStatusKind.LuaFailure => LuaOperationStatus.LuaFailure(LuaStatus.RuntimeError), + LuaOperationStatusKind.NilResult => LuaOperationStatus.NilResult, + LuaOperationStatusKind.InvalidResult => LuaOperationStatus.InvalidResult, + LuaOperationStatusKind.StackUnavailable => LuaOperationStatus.StackUnavailable, + LuaOperationStatusKind.MissingResult => LuaOperationStatus.MissingResult, + LuaOperationStatusKind.ResultCapacityExceeded => LuaOperationStatus.ResultCapacityExceeded, + _ => default + }; + } + + /// Stops the activation after the dispatch admission, then runs the callback inline. + private sealed class StoppingMainThreadInvoker(ControlledCoreLifetimeContext context) : IMainThreadInvoker + { + private readonly InlineMainThreadInvoker _inner = new(); + + public Exception? Invoke(Action callback) + { + context.Stop(); + return _inner.Invoke(callback); + } + + public MainThreadInvocationResult Invoke(Func callback) + { + context.Stop(); + return _inner.Invoke(callback); + } + } + + /// Runs every callback inline and counts the dispatches. + private sealed class CountingMainThreadInvoker : IMainThreadInvoker + { + private readonly InlineMainThreadInvoker _inner = new(); + + internal int Invocations + { + get; + private set; + } + + public Exception? Invoke(Action callback) + { + Invocations++; + return _inner.Invoke(callback); + } + + public MainThreadInvocationResult Invoke(Func callback) + { + Invocations++; + return _inner.Invoke(callback); + } + } + private sealed class FakeInspectionPort : IInspectionPort { + private readonly Dictionary _current = new(StringComparer.Ordinal); + internal int CurrentProcessModuleCalls { get; @@ -257,7 +819,7 @@ internal SymbolExpression LastAddressExpression private set; } - internal AddressResolutionOptions LastAddressOptions + internal AddressResolutionMode LastAddressMode { get; private set; @@ -280,7 +842,7 @@ internal Address ResolvedAddress init; } - internal bool ResolveNameResult + internal LuaOperationStatus NameStatus { get; init; @@ -292,7 +854,7 @@ internal string? ResolvedName init; } - internal nuint LastNameAddress + internal Address LastNameAddress { get; private set; @@ -310,7 +872,7 @@ internal string? LastRegisteredName private set; } - internal nuint LastRegisteredAddress + internal Address LastRegisteredAddress { get; private set; @@ -327,6 +889,41 @@ internal List UnregisteredNames get; } = []; + /// Gets the number of SDK lease releases the Client requested. + internal int ReleaseCalls + { + get; + private set; + } + + /// Gets or sets the registration status the fake SDK coordinator reports. + internal LuaOperationStatus RegistrationStatus + { + get; + set; + } = LuaOperationStatus.Success; + + /// Gets or sets whether a registration returns no lease, whatever its status. + internal bool OmitHandle + { + get; + set; + } + + /// Gets or sets the exception the fake SDK coordinator raises from the registration. + internal Exception? RegistrationFault + { + get; + set; + } + + /// Gets the callback that runs inside the registration, on the main thread. + internal Action? OnRegister + { + get; + init; + } + public int ModulesWritten { get; @@ -379,33 +976,130 @@ public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo sy return InspectionStatus.Success; } - public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + /// Gets Cheat Engine's registered symbols as the fake models them (ordinal names). + internal Dictionary Symbols + { + get; + } = new(StringComparer.Ordinal); + + /// Forces the status of every address resolution, for example a failed collision check. + internal InspectionStatus? ResolveStatusOverride + { + get; + set; + } + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, out Address address) { LastAddressExpression = expression; - LastAddressOptions = options; + LastAddressMode = mode; + if (ResolveStatusOverride is { } forced) + { + address = default; + return forced; + } + + if (Symbols.TryGetValue(expression.Value, out address)) + { + return InspectionStatus.Success; + } + + // An unregistered name does not resolve unless the test configured an address for any expression. address = ResolvedAddress; - return InspectionStatus.Success; + return ResolvedAddress == Address.Zero ? InspectionStatus.NotFound : InspectionStatus.Success; } - public bool TryResolveName(nuint address, out string? name) + public LuaOperationStatus TryGetName(Address address, out string? name) { LastNameAddress = address; name = ResolvedName; - return ResolveNameResult; + return NameStatus; } - public void RegisterSymbol(string name, nuint address, bool doNotSave) + public SymbolRegistrationAttempt TryRegisterOwned(SymbolName name, Address address, + SymbolRegistrationOptions options) { RegisterCalls++; - LastRegisteredName = name; + LastRegisteredName = name.Value; LastRegisteredAddress = address; - LastRegisteredDoNotSave = doNotSave; + LastRegisteredDoNotSave = options.DoNotSave; + OnRegister?.Invoke(); + if (RegistrationFault is { } fault) + { + throw fault; + } + + if (!RegistrationStatus.IsSuccess || OmitHandle) + { + return new SymbolRegistrationAttempt(RegistrationStatus, null); + } + + return new SymbolRegistrationAttempt(LuaOperationStatus.Success, Register(name.Value, address)); + } + + /// Registers a name through the same coordinator as another owner would, superseding the current lease. + internal void RegisterThroughCoordinator(string name, Address address) + { + _ = Register(name, address); } - public void UnregisterSymbol(string name) + private Registration Register(string name, Address address) { - UnregisteredNames.Add(name); + Symbols[name] = address; + if (_current.Remove(name, out Registration? existing)) + { + existing.Supersede(); + } + + Registration registration = new(this, name, address); + _current[name] = registration; + return registration; + } + + /// Models CheatEngine.SDK's SymbolRegistrationLease.Release over the fake symbol table. + private sealed class Registration(FakeInspectionPort owner, string name, Address address) + : ISymbolRegistrationHandle + { + private SymbolRegistrationReleaseKind? _terminal; + + internal void Supersede() + { + _terminal = SymbolRegistrationReleaseKind.Superseded; + } + + public SymbolRegistrationReleaseKind Release() + { + owner.ReleaseCalls++; + if (_terminal is { } terminal) + { + _terminal = SymbolRegistrationReleaseKind.AlreadyReleased; + return terminal; + } + + InspectionStatus lookup = owner.ResolveAddress(new SymbolExpression(name), default, out Address current); + SymbolRegistrationReleaseKind kind = lookup switch + { + InspectionStatus.Success when current == address => SymbolRegistrationReleaseKind.Released, + InspectionStatus.Success => SymbolRegistrationReleaseKind.Replaced, + InspectionStatus.NotFound => SymbolRegistrationReleaseKind.ExternallyRemoved, + _ => SymbolRegistrationReleaseKind.CleanupUnavailable + }; + if (kind == SymbolRegistrationReleaseKind.CleanupUnavailable) + { + return kind; + } + + if (kind == SymbolRegistrationReleaseKind.Released) + { + owner.UnregisteredNames.Add(name); + owner.Symbols.Remove(name); + } + + owner._current.Remove(name); + _terminal = SymbolRegistrationReleaseKind.AlreadyReleased; + return kind; + } } } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionMappingTests.cs new file mode 100644 index 0000000..1e17b69 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionMappingTests.cs @@ -0,0 +1,131 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// Every status of the CheatEngine.SDK 2.0.0 symbol registry has a deliberate Client counterpart, and a value the SDK +/// could add later fails closed (Q48). +/// +public sealed class InspectionMappingTests +{ + private static readonly Dictionary NameLookupKinds = new() + { + [LuaOperationStatusKind.Unknown] = CheatEngineFailureKind.IndeterminateHostResult, + [LuaOperationStatusKind.Success] = CheatEngineFailureKind.InvalidHostResult, + [LuaOperationStatusKind.GlobalUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [LuaOperationStatusKind.LuaFailure] = CheatEngineFailureKind.LuaError, + [LuaOperationStatusKind.NilResult] = CheatEngineFailureKind.NotFound, + [LuaOperationStatusKind.InvalidResult] = CheatEngineFailureKind.InvalidHostResult, + [LuaOperationStatusKind.StackUnavailable] = CheatEngineFailureKind.LuaError, + [LuaOperationStatusKind.MissingResult] = CheatEngineFailureKind.InvalidHostResult, + [LuaOperationStatusKind.ResultCapacityExceeded] = CheatEngineFailureKind.InvalidHostResult + }; + + private static readonly Dictionary + RegistrationFailures = new() + { + [LuaOperationStatusKind.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + [LuaOperationStatusKind.Success] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed), + [LuaOperationStatusKind.GlobalUnavailable] = + (CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.Unknown), + [LuaOperationStatusKind.LuaFailure] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.NilResult] = (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.InvalidResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.StackUnavailable] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.NotStarted), + [LuaOperationStatusKind.MissingResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.ResultCapacityExceeded] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started) + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryNameLookupStatusIsMappedAndAnUnknownStatusFailsClosed() + { + MappingTotality.AssertTotal( + static status => NameLookupKinds.TryGetValue(status, out CheatEngineFailureKind expected) && + InspectionMapping.ToNameLookupFailureKind(status) == expected, + static status => InspectionMapping.ToNameLookupFailureKind(status) == + CheatEngineFailureKind.IndeterminateHostResult); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryRegistrationStatusIsMappedAndAnUnknownStatusFailsClosed() + { + MappingTotality.AssertTotal( + static status => RegistrationFailures.TryGetValue(status, + out (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) expected) && + InspectionMapping.ToRegistrationFailureKind(status) == expected.Kind && + InspectionMapping.ToRegistrationHostEffect(status) == expected.Effect, + static status => + InspectionMapping.ToRegistrationFailureKind(status) == CheatEngineFailureKind.IndeterminateHostResult && + InspectionMapping.ToRegistrationHostEffect(status) == CheatEngineHostEffect.Unknown); + } + + [Theory] + [InlineData(LuaOperationStatusKind.NilResult, CheatEngineFailureKind.NotFound, + "Cheat Engine did not return a symbol name for the requested address.")] + [InlineData(LuaOperationStatusKind.Success, CheatEngineFailureKind.InvalidHostResult, + "Cheat Engine returned an invalid symbol-name result.")] + [InlineData(LuaOperationStatusKind.LuaFailure, CheatEngineFailureKind.LuaError, + "The Cheat Engine symbol-name lookup returned 'LuaFailure'.")] + public void NameLookupFailuresNameTheCategoryOnly(LuaOperationStatusKind status, CheatEngineFailureKind kind, + string message) + { + CheatEngineFailure failure = InspectionMapping.NameLookupFailure("Inspection.ResolveName", status); + + Assert.Equal(kind, failure.Kind); + Assert.Equal("Inspection.ResolveName", failure.Operation); + Assert.Equal(message, failure.Message); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + } + + [Fact] + public void ANameLookupWhoseGlobalIsUnavailableDidNotStart() + { + CheatEngineFailure failure = + InspectionMapping.NameLookupFailure("Inspection.ResolveName", LuaOperationStatusKind.GlobalUnavailable); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void TheResolutionModeReachesTheSdkAsItsShallowArgument() + { + // CheatEngine.SDK 2.0 binds the single positional argument of AddressResolutionOptions to Shallow (1.x had two): + // a change of that constructor fails here rather than silently dropping the Shallow mode. + Assert.True(new AddressResolutionOptions(true).Shallow); + Assert.False(new AddressResolutionOptions(false).Shallow); + Assert.Equal(new AddressResolutionOptions(true), + InspectionMapping.ToSdkResolutionOptions(AddressResolutionMode.Shallow)); + Assert.Equal(default, InspectionMapping.ToSdkResolutionOptions(AddressResolutionMode.Default)); + Assert.All(Enum.GetValues(), static mode => Assert.Equal( + mode == AddressResolutionMode.Shallow, InspectionMapping.ToSdkResolutionOptions(mode).Shallow)); + } + + [Fact] + public void ARegistrationFailureCarriesTheMappedKindAndEffect() + { + CheatEngineFailure failure = + InspectionMapping.RegistrationFailure("Inspection.RegisterSymbol", LuaOperationStatusKind.LuaFailure); + + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.Equal("Inspection.RegisterSymbol", failure.Operation); + Assert.Null(failure.Exception); + Assert.Equal( + "The symbol registration through the CheatEngine.SDK ownership coordinator returned 'LuaFailure' without a " + + "lease.", failure.Message); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InstructionMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InstructionMappingTests.cs new file mode 100644 index 0000000..367f250 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InstructionMappingTests.cs @@ -0,0 +1,159 @@ +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Assembly; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// Every CheatEngine.SDK 2.0.0 instruction status has its Client result in each phase of a call, and a status the +/// SDK could add later fails closed (plan L18). +/// +public sealed class InstructionMappingTests +{ + private const string Operation = "Assembly.Disassemble"; + + /// The Client failure kind of each status; for a success. + private static readonly Dictionary Kinds = new() + { + [InstructionOperationStatus.Unknown] = CheatEngineFailureKind.IndeterminateHostResult, + [InstructionOperationStatus.Success] = null, + [InstructionOperationStatus.InvalidProfile] = CheatEngineFailureKind.InvalidHostResult, + [InstructionOperationStatus.AddressExceedsProfileWidth] = CheatEngineFailureKind.OperationRejected, + [InstructionOperationStatus.TargetNotSelected] = CheatEngineFailureKind.TargetNotAttached, + [InstructionOperationStatus.TargetChanged] = CheatEngineFailureKind.TargetChanged, + [InstructionOperationStatus.DestinationTooSmall] = CheatEngineFailureKind.ResultLimitExceeded, + [InstructionOperationStatus.OutputTooLong] = CheatEngineFailureKind.ResultLimitExceeded, + [InstructionOperationStatus.InstructionRejected] = CheatEngineFailureKind.OperationRejected, + [InstructionOperationStatus.GlobalUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [InstructionOperationStatus.LuaFailure] = CheatEngineFailureKind.LuaError, + [InstructionOperationStatus.InvalidResult] = CheatEngineFailureKind.InvalidHostResult, + [InstructionOperationStatus.UnsupportedTargetBackend] = CheatEngineFailureKind.Unsupported + }; + + /// The host effect of each failed status reported by the operation itself. + private static readonly Dictionary OperationEffects = new() + { + [InstructionOperationStatus.Unknown] = CheatEngineHostEffect.Unknown, + [InstructionOperationStatus.InvalidProfile] = CheatEngineHostEffect.Unknown, + [InstructionOperationStatus.AddressExceedsProfileWidth] = CheatEngineHostEffect.NotStarted, + [InstructionOperationStatus.TargetNotSelected] = CheatEngineHostEffect.Unknown, + [InstructionOperationStatus.TargetChanged] = CheatEngineHostEffect.Unknown, + [InstructionOperationStatus.DestinationTooSmall] = CheatEngineHostEffect.Completed, + [InstructionOperationStatus.OutputTooLong] = CheatEngineHostEffect.Completed, + [InstructionOperationStatus.InstructionRejected] = CheatEngineHostEffect.NotApplied, + [InstructionOperationStatus.GlobalUnavailable] = CheatEngineHostEffect.NotStarted, + [InstructionOperationStatus.LuaFailure] = CheatEngineHostEffect.Unknown, + [InstructionOperationStatus.InvalidResult] = CheatEngineHostEffect.Unknown, + [InstructionOperationStatus.UnsupportedTargetBackend] = CheatEngineHostEffect.Unknown + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryStatusMapsToSuccessOrItsFailureKind() + { + MappingTotality.AssertTotal( + static status => Kinds.TryGetValue(status, out CheatEngineFailureKind? expected) && + Map(status, InstructionCallPhase.Operation)?.Kind == expected && + Map(status, InstructionCallPhase.ProfileObservation)?.Kind == expected && + Map(status, InstructionCallPhase.AfterEarlierCall)?.Kind == expected, + static status => Map(status, InstructionCallPhase.Operation) is + { + Kind: CheatEngineFailureKind.IndeterminateHostResult, HostEffect: CheatEngineHostEffect.Unknown + }); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryFailedStatusOfTheOperationHasItsHostEffect() + { + MappingTotality.AssertTotal( + static status => status == InstructionOperationStatus.Success + ? Map(status, InstructionCallPhase.Operation) is null + : OperationEffects.TryGetValue(status, out CheatEngineHostEffect expected) && + Map(status, InstructionCallPhase.Operation)?.HostEffect == expected, + static status => InstructionMapping.ToHostEffect(status, InstructionCallPhase.Operation) == + CheatEngineHostEffect.Unknown); + } + + [Fact] + public void AStatusOfTheProfileObservationNeverStartedTheInstructionOperation() + { + foreach (InstructionOperationStatus status in Enum.GetValues() + .Where(static status => status != InstructionOperationStatus.Success)) + { + CheatEngineFailure failure = Map(status, InstructionCallPhase.ProfileObservation)!.Value; + + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(Operation, failure.Operation); + Assert.Null(failure.Exception); + Assert.False(string.IsNullOrWhiteSpace(failure.Message)); + } + } + + [Fact] + public void AStatusAfterAnEarlierCallIsNeverNotStarted() + { + foreach (InstructionOperationStatus status in Enum.GetValues() + .Where(static status => status != InstructionOperationStatus.Success)) + { + CheatEngineHostEffect first = Map(status, InstructionCallPhase.Operation)!.Value.HostEffect; + CheatEngineHostEffect later = Map(status, InstructionCallPhase.AfterEarlierCall)!.Value.HostEffect; + + Assert.Equal(first == CheatEngineHostEffect.NotStarted ? CheatEngineHostEffect.Completed : first, later); + } + + Assert.Equal(CheatEngineHostEffect.Completed, + Map(InstructionOperationStatus.GlobalUnavailable, InstructionCallPhase.AfterEarlierCall)!.Value.HostEffect); + } + + [Fact] + public void AnEarlierCallTurnsOnlyANotStartedFailureIntoACompletedOne() + { + CheatEngineFailure notStarted = new(CheatEngineFailureKind.CapabilityUnavailable, Operation, "Unavailable.", + null, CheatEngineHostEffect.NotStarted); + CheatEngineFailure unknown = new(CheatEngineFailureKind.MemoryReadFailed, Operation, "Read failed."); + + CheatEngineFailure restated = InstructionMapping.AfterEarlierCall(notStarted); + + Assert.Equal((CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.Completed), + (restated.Kind, restated.HostEffect)); + Assert.Equal(notStarted.Operation, restated.Operation); + Assert.Equal(notStarted.Message, restated.Message); + Assert.Equal(unknown, InstructionMapping.AfterEarlierCall(unknown)); + } + + [Fact] + public void TheClientRefusalsNameTheirEffectWithoutAnAddress() + { + CheatEngineFailure beforeCall = InstructionMapping.AddressOutsideProfile(Operation); + CheatEngineFailure afterCall = InstructionMapping.ReturnedAddressOutsideProfile(Operation); + CheatEngineFailure limit = InstructionMapping.ResultExceedsLimit(Operation, 40, 32); + CheatEngineFailure invalid = InstructionMapping.InvalidResult(Operation, "Shape violated."); + + Assert.Equal((CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.NotStarted), + (beforeCall.Kind, beforeCall.HostEffect)); + Assert.Equal((CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.Completed), + (afterCall.Kind, afterCall.HostEffect)); + Assert.Equal((CheatEngineFailureKind.ResultLimitExceeded, CheatEngineHostEffect.Completed), + (limit.Kind, limit.HostEffect)); + Assert.Contains("40", limit.Message, StringComparison.Ordinal); + Assert.Contains("32", limit.Message, StringComparison.Ordinal); + Assert.Equal((CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + (invalid.Kind, invalid.HostEffect)); + Assert.DoesNotContain("0x", beforeCall.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("0x", afterCall.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ABlankOperationIsAClientDefect() + { + Assert.Throws(() => + InstructionMapping.ToFailure(" ", InstructionOperationStatus.LuaFailure, InstructionCallPhase.Operation)); + } + + private static CheatEngineFailure? Map(InstructionOperationStatus status, InstructionCallPhase phase) + { + return InstructionMapping.ToFailure(Operation, status, phase); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs deleted file mode 100644 index f515685..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessDiagnosticsTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using CheatEngine.Client.Core.Domains; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Core.Tests.TestSupport; -using CheatEngine.Client.Processes; -using CheatEngine.Client.Results; -using CheatEngine.SDK.Engine.Runtime; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class LocalProcessDiagnosticsTests -{ - [Fact] - public void GetProcessesReturnsBoundedOrderedCopiedLocalMetadata() - { - FakeLocalProcessHost host = new( - [ - new LocalProcessInfo(52, "alpha-worker", "C:\\fixtures\\alpha-worker.exe"), - new LocalProcessInfo(43, "alpha-server", "C:\\fixtures\\alpha-server.exe"), - new LocalProcessInfo(44, "beta", "C:\\fixtures\\beta.exe") - ]); - LocalProcessDiagnostics diagnostics = new(host); - - ProcessEnumerationResult result = diagnostics.GetProcesses( - new ProcessEnumerationRequest(1, "ALPHA"), - TestContext.Current.CancellationToken); - - Assert.True(result.IsTruncated); - ProcessInfoSnapshot snapshot = Assert.Single(result.Processes); - Assert.Equal(new LocalProcessId(43), snapshot.Id); - Assert.Equal("alpha-server", snapshot.Name); - Assert.Equal("C:\\fixtures\\alpha-server.exe", snapshot.ExecutablePath); - } - - [Fact] - public void TryGetProcessesHonorsCancellationBeforeReadingTheLocalCatalog() - { - FakeLocalProcessHost host = new([]); - LocalProcessDiagnostics diagnostics = new(host); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - bool succeeded = diagnostics.TryGetProcesses( - new ProcessEnumerationRequest(1), - out ProcessEnumerationResult result, - out CheatEngineFailure failure, - cancellation.Token); - - Assert.False(succeeded); - Assert.Equal(default, result); - Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); - Assert.Equal("LocalProcesses.GetProcesses", failure.Operation); - Assert.Equal(0, host.GetLocalProcessesCalls); - } - - [Fact] - public void TryGetProcessesRejectsAnInvalidRequestBeforeReadingTheLocalCatalog() - { - FakeLocalProcessHost host = new([]); - LocalProcessDiagnostics diagnostics = new(host); - - Assert.Throws(() => - diagnostics.TryGetProcesses(default, out _, out _, TestContext.Current.CancellationToken)); - - Assert.Equal(0, host.GetLocalProcessesCalls); - } - - [Fact] - public void TryGetProcessesMapsLocalCatalogFailuresWithoutClaimingTargetState() - { - FakeLocalProcessHost host = new([]) - { - GetLocalProcessesException = new InvalidOperationException("fixture enumeration failed") - }; - LocalProcessDiagnostics diagnostics = new(host); - - bool succeeded = diagnostics.TryGetProcesses( - new ProcessEnumerationRequest(1), - out ProcessEnumerationResult result, - out CheatEngineFailure failure, - TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Equal(default, result); - Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); - Assert.Equal("LocalProcesses.GetProcesses", failure.Operation); - Assert.Equal(1, host.GetLocalProcessesCalls); - } - - [Fact] - public void CopiedLocalDiagnosticsRemainUsableAfterAClientActivationExpires() - { - FakeLocalProcessHost host = new( - [ - new LocalProcessInfo(43, "fixture", "C:\\fixtures\\fixture.exe") - ]); - LocalProcessDiagnostics diagnostics = new(host); - using ControlledCoreLifetimeContext context = new(); - using CoreLifetime lifetime = new(context); - ProcessInfoSnapshot snapshot = Assert.Single(diagnostics.GetProcesses( - new ProcessEnumerationRequest(1), TestContext.Current.CancellationToken).Processes); - context.IsCurrent = false; - - Assert.Throws(() => lifetime.ThrowIfInactive("Test.Stale")); - Assert.Equal(new LocalProcessId(43), snapshot.Id); - Assert.Equal("fixture", snapshot.Name); - } - - private sealed class FakeLocalProcessHost(IReadOnlyList processes) : IProcessHost - { - internal int GetLocalProcessesCalls - { - get; - private set; - } - - internal Exception? GetLocalProcessesException - { - get; - init; - } - - public long GetOpenedProcessId() - { - return 0; - } - - public void OpenProcess(long processId) - { - throw new NotSupportedException(); - } - - public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) - { - process = default; - return false; - } - - public IReadOnlyList GetLocalProcesses() - { - GetLocalProcessesCalls++; - if (GetLocalProcessesException is { } exception) - { - throw exception; - } - - return processes; - } - - public IReadOnlyList FindProcessesByExactName(string processName) - { - return []; - } - - public CheatEngineArchitecture GetTargetArchitecture() - { - return CheatEngineArchitecture.Unknown; - } - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs deleted file mode 100644 index e2e9a99..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CheatEngine.Client.Core.Domains; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class LuaRuntimeProbeTests -{ - [Fact] - public void GetCheatEngineVersionRequiresAnEnabledPluginContext() - { - LuaRuntimeProbe probe = new(); - - InvalidOperationException exception = - Assert.Throws(() => probe.GetCheatEngineVersion()); - - Assert.Contains("plugin is not enabled", exception.Message, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs index 6f52972..0430847 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs @@ -11,17 +11,20 @@ namespace CheatEngine.Client.Core.Tests.Domains; public sealed class MemoryClientBehaviorCoverageTests { - private static readonly Address _address = new(0x405000); + private static readonly Address TestAddress = new(0x405000); [Fact] public void TypedReadForwardsCancellationAndReturnsTheCodecValue() { CancellationToken cancellationToken = TestContext.Current.CancellationToken; RecordingDispatcher dispatcher = new(); - ProbeCodec codec = new() { ReadValue = 1337 }; + ProbeCodec codec = new() + { + ReadValue = 1337 + }; MemoryClient client = new(dispatcher, InertCoreLifetime.Create()); - bool succeeded = client.TryRead(new MemoryReadRequest(_address, codec), out int value, + bool succeeded = client.TryRead(new MemoryReadRequest(TestAddress, codec), out int value, out CheatEngineFailure failure, cancellationToken); Assert.True(succeeded); @@ -30,7 +33,7 @@ public void TypedReadForwardsCancellationAndReturnsTheCodecValue() Assert.Equal(1, dispatcher.ActionInvocationCount); Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); Assert.Equal(1, codec.ReadCount); - Assert.Equal(_address, codec.LastReadAddress); + Assert.Equal(TestAddress, codec.LastReadAddress); Assert.NotNull(codec.LastReadContext); } @@ -42,7 +45,7 @@ public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec() ProbeCodec codec = new(); MemoryClient client = new(dispatcher, InertCoreLifetime.Create()); - bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 42, codec), + bool succeeded = client.TryWrite(new MemoryWriteRequest(TestAddress, 42, codec), out CheatEngineFailure failure, cancellationToken); Assert.True(succeeded); @@ -50,7 +53,7 @@ public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec() Assert.Equal(1, dispatcher.ActionInvocationCount); Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); Assert.Equal(1, codec.WriteCount); - Assert.Equal(_address, codec.LastWriteAddress); + Assert.Equal(TestAddress, codec.LastWriteAddress); Assert.Equal(42, codec.LastWrittenValue); Assert.NotNull(codec.LastWriteContext); } @@ -58,9 +61,12 @@ public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec() [Fact] public void FailedCodecReadUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt() { - ProbeCodec codec = new() { ReadSucceeds = false }; + ProbeCodec codec = new() + { + ReadSucceeds = false + }; MemoryClient client = new(new RecordingDispatcher(), InertCoreLifetime.Create()); - MemoryReadRequest request = new(_address, codec); + MemoryReadRequest request = new(TestAddress, codec); bool succeeded = client.TryRead(request, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -79,9 +85,12 @@ public void FailedCodecReadUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt [Fact] public void FailedCodecWriteUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt() { - ProbeCodec codec = new() { WriteSucceeds = false }; + ProbeCodec codec = new() + { + WriteSucceeds = false + }; MemoryClient client = new(new RecordingDispatcher(), InertCoreLifetime.Create()); - MemoryWriteRequest request = new(_address, 77, codec); + MemoryWriteRequest request = new(TestAddress, 77, codec); bool succeeded = client.TryWrite(request, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -106,8 +115,8 @@ public void CancelledDispatchPreventsCodecExecutionAndPreservesTheCancellationFa CancellationAwareDispatcher dispatcher = new(); ProbeCodec codec = new(); MemoryClient client = new(dispatcher, InertCoreLifetime.Create()); - MemoryReadRequest readRequest = new(_address, codec); - MemoryWriteRequest writeRequest = new(_address, 9, codec); + MemoryReadRequest readRequest = new(TestAddress, codec); + MemoryWriteRequest writeRequest = new(TestAddress, 9, codec); bool readSucceeded = client.TryRead(readRequest, out int value, out CheatEngineFailure readFailure, cancellationToken); @@ -126,6 +135,30 @@ public void CancelledDispatchPreventsCodecExecutionAndPreservesTheCancellationFa Assert.Equal(0, codec.WriteCount); } + /// The throwing forms raise the cancellation exception with the caller's token, never an operation failure. + [Fact] + public void CancelledThrowingReadAndWriteRaiseOperationCanceledExceptionWithTheCallersToken() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + CancellationToken cancellationToken = cancellation.Token; + CancellationAwareDispatcher dispatcher = new(); + ProbeCodec codec = new(); + MemoryClient client = new(dispatcher, InertCoreLifetime.Create()); + + CheatEngineOperationCanceledException read = Assert.Throws(() => + client.Read(new MemoryReadRequest(TestAddress, codec), cancellationToken)); + CheatEngineOperationCanceledException write = Assert.Throws(() => + client.Write(new MemoryWriteRequest(TestAddress, 9, codec), cancellationToken)); + + Assert.Equal(cancellationToken, read.CancellationToken); + Assert.Equal(CheatEngineFailureKind.Cancelled, read.Failure.Kind); + Assert.Equal(cancellationToken, write.CancellationToken); + Assert.Equal(CheatEngineFailureKind.Cancelled, write.Failure.Kind); + Assert.Equal(0, codec.ReadCount); + Assert.Equal(0, codec.WriteCount); + } + [Fact] public void ConstructorRejectsANullDispatcher() { @@ -195,8 +228,9 @@ internal IMemoryWriteContext? LastWriteContext private set; } - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; LastReadContext = context; LastReadAddress = address; ReadCount++; @@ -204,8 +238,9 @@ public bool TryRead(IMemoryReadContext context, Address address, out int value) return ReadSucceeds; } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; LastWriteContext = context; LastWriteAddress = address; LastWrittenValue = value; @@ -254,7 +289,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -265,7 +300,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs index c35133e..7f7d4d9 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs @@ -1,7 +1,9 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Core.Tests.TestSupport; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Memory; @@ -60,8 +62,9 @@ public void ByteAndStringOperationsPreserveTheDispatcherFailureAndEmptyReadResul MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create()); MemoryBytesReadRequest byteRead = new(Address, 2); MemoryBytesWriteRequest byteWrite = new(Address, [0x10, 0x20]); - MemoryStringReadRequest stringRead = new(Address, 12, true); - MemoryStringWriteRequest stringWrite = new(Address, "health", true); + MemoryStringReadRequest stringRead = new MemoryStringReadRequest(Address, 12, MemoryStringEncoding.Utf16); + MemoryStringWriteRequest stringWrite = + new MemoryStringWriteRequest(Address, "health", 6, MemoryStringEncoding.Utf16); Assert.False(client.TryReadBytes(byteRead, out ImmutableArray bytes, out CheatEngineFailure byteReadFailure, @@ -113,6 +116,39 @@ public void PrimitiveBatchesPreserveTheDispatcherFailureWithoutAdmittingAnyTarge Assert.Equal(expected, writeFailure); } + [Fact] + [Trait("Qualification", "Q33")] + public void PreAdmissionCancelledBatchWriteReportsNotStarted() + { + CoreLifetime lifetime = InertCoreLifetime.Create(); + MemoryClient client = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime); + MemoryPrimitiveBatchWriteRequest writes = new([new MemoryAddressValue(Address, 12)]); + + MemoryPrimitiveBatchWriteOutcome outcome = + client.WritePrimitiveBatchDetailed(writes, new CancellationToken(true)); + + Assert.False(outcome.IsSuccess); + Assert.Equal(0, outcome.CompletedCount); + Assert.Null(outcome.FailedIndex); + Assert.Equal(MemoryBatchWriteEffectState.NotStarted, outcome.EffectState); + Assert.Equal(CheatEngineFailureKind.Cancelled, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, outcome.Failure.Value.HostEffect); + } + + [Fact] + public void NonCancellationDispatchFailureOfABatchWriteKeepsAnUnknownEffect() + { + CheatEngineFailure expected = Failure("Test.Batch"); + MemoryClient client = new(new RejectingDispatcher(expected), InertCoreLifetime.Create()); + + MemoryPrimitiveBatchWriteOutcome outcome = client.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(Address, 12)]), + TestContext.Current.CancellationToken); + + Assert.Equal(MemoryBatchWriteEffectState.Unknown, outcome.EffectState); + Assert.Equal(expected, outcome.Failure); + } + [Fact] public void DefaultPrimitiveBatchesAreRejectedBeforeDispatch() { @@ -126,23 +162,24 @@ public void DefaultPrimitiveBatchesAreRejectedBeforeDispatch() TestContext.Current.CancellationToken)); } + /// + /// A5: the primitive members support exactly the 8- to 64-bit integers, float, double and Address. Any other + /// unmanaged type is refused before dispatch: a rejecting dispatcher would otherwise report its own failure. + /// [Fact] - public void UnsupportedPrimitiveTypesReturnTheSpecificUnsupportedFailureWithoutAccessingTheHost() + [Trait("Qualification", "Q20")] + public void UnsupportedPrimitiveTypesAreRefusedBeforeDispatchWithoutAHostCall() { - MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create()); - - bool readSucceeded = client.TryReadPrimitive(Address, out DateTime readValue, - out CheatEngineFailure readFailure, TestContext.Current.CancellationToken); - bool writeSucceeded = client.TryWritePrimitive(Address, DateTime.UnixEpoch, out CheatEngineFailure writeFailure, - TestContext.Current.CancellationToken); + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")), + InertCoreLifetime.Create()); - Assert.False(readSucceeded); - Assert.Equal(default, readValue); - Assert.Equal(CheatEngineFailureKind.Unsupported, readFailure.Kind); - Assert.Equal("Memory.ReadPrimitive", readFailure.Operation); - Assert.False(writeSucceeded); - Assert.Equal(CheatEngineFailureKind.Unsupported, writeFailure.Kind); - Assert.Equal("Memory.WritePrimitive", writeFailure.Operation); + AssertRefusedBeforeDispatch(client, DateTime.UnixEpoch); + AssertRefusedBeforeDispatch(client, 'A'); + AssertRefusedBeforeDispatch(client, true); + AssertRefusedBeforeDispatch(client, (nint) 1); + AssertRefusedBeforeDispatch(client, 1m); + AssertRefusedBeforeDispatch(client, Guid.Empty); + AssertRefusedBeforeDispatch(client, (Half) 1); } [Fact] @@ -152,12 +189,12 @@ public void ReadAndWriteConvenienceMethodsThrowTheClassifiedDispatcherFailure() MemoryReadRequest read = new(Address, new NeverUsedCodec()); MemoryWriteRequest write = new(Address, 42, new NeverUsedCodec()); - CheatEngineClientLifecycleException primitiveException = - Assert.Throws(() => + CheatEngineInvalidStateException primitiveException = + Assert.Throws(() => client.ReadPrimitive(Address, TestContext.Current.CancellationToken)); - CheatEngineClientLifecycleException readException = Assert.Throws(() => + CheatEngineInvalidStateException readException = Assert.Throws(() => client.Read(read, TestContext.Current.CancellationToken)); - CheatEngineClientLifecycleException writeException = Assert.Throws(() => + CheatEngineInvalidStateException writeException = Assert.Throws(() => client.Write(write, TestContext.Current.CancellationToken)); Assert.Equal("Test.Convenience", primitiveException.Failure.Operation); @@ -203,6 +240,70 @@ public void DefaultStringAndByteWritesAreRejectedBeforeDispatch() client.TryWriteString(default, out _, TestContext.Current.CancellationToken)); } + /// + /// A string request tampered past its constructor throws what that constructor throws for the same value, from + /// both forms and before dispatch: an undefined encoding would otherwise be read or written as UTF-8. + /// + [Theory] + [InlineData("Read.Encoding", "request")] + [InlineData("Write.Encoding", "request")] + [InlineData("Write.MaximumLength", "request.MaximumLength")] + public void ATamperedStringRequestThrowsBeforeDispatch(string tampered, string parameter) + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch")), + InertCoreLifetime.Create()); + CancellationToken token = TestContext.Current.CancellationToken; + MemoryStringReadRequest read = TamperedValues.WithBackingField( + new MemoryStringReadRequest(Address, 16, MemoryStringEncoding.Utf8), + nameof(MemoryStringReadRequest.Encoding), (MemoryStringEncoding) 7); + MemoryStringWriteRequest write = new(Address, string.Empty, 16, MemoryStringEncoding.Utf8); + MemoryStringWriteRequest tamperedWrite = tampered == "Write.MaximumLength" + ? TamperedValues.WithBackingField(write, nameof(MemoryStringWriteRequest.MaximumLength), 0) + : TamperedValues.WithBackingField(write, nameof(MemoryStringWriteRequest.Encoding), + (MemoryStringEncoding) 7); + (Action TryForm, Action ThrowingForm) forms = tampered switch + { + "Read.Encoding" => (() => client.TryReadString(read, out _, out _, token), + () => client.ReadString(read, token)), + "Write.Encoding" or "Write.MaximumLength" => (() => client.TryWriteString(tamperedWrite, out _, token), + () => client.WriteString(tamperedWrite, token)), + _ => throw new ArgumentOutOfRangeException(nameof(tampered), tampered, null) + }; + + ArgumentOutOfRangeException thrown = Assert.Throws(forms.TryForm); + ArgumentOutOfRangeException throwingForm = Assert.Throws(forms.ThrowingForm); + + Assert.Equal(parameter, thrown.ParamName); + Assert.Equal(thrown.Message, throwingForm.Message); + } + + private static void AssertRefusedBeforeDispatch(MemoryClient client, T sample) + where T : unmanaged + { + CancellationToken token = TestContext.Current.CancellationToken; + bool read = client.TryReadPrimitive(Address, out T _, out CheatEngineFailure readFailure, token); + bool written = client.TryWritePrimitive(Address, sample, out CheatEngineFailure writeFailure, token); + MemoryPrimitiveBatchReadOutcome readBatch = + client.ReadPrimitiveBatchDetailed(new MemoryPrimitiveBatchReadRequest([Address]), token); + MemoryPrimitiveBatchWriteOutcome writeBatch = client.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(Address, sample)]), token); + + Assert.False(read); + Assert.False(written); + Assert.Equal(MemoryBatchWriteEffectState.NotStarted, writeBatch.EffectState); + Assert.Equal(0, readBatch.CompletedCount); + foreach ((CheatEngineFailure failure, string operation) in (ReadOnlySpan<(CheatEngineFailure, string)>) + [ + (readFailure, "Memory.ReadPrimitive"), (writeFailure, "Memory.WritePrimitive"), + (readBatch.Failure!.Value, "Memory.ReadPrimitiveBatch"), + (writeBatch.Failure!.Value, "Memory.WritePrimitiveBatch") + ]) + { + Assert.Equal((CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.NotStarted, operation), + (failure.Kind, failure.HostEffect, failure.Operation)); + } + } + private static CheatEngineFailure Failure(string operation) { return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, "The dispatcher rejected work."); @@ -210,13 +311,15 @@ private static CheatEngineFailure Failure(string operation) private sealed class NeverUsedCodec : IMemoryCodec { - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; throw new InvalidOperationException("The rejecting dispatcher must prevent codec execution."); } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; throw new InvalidOperationException("The rejecting dispatcher must prevent codec execution."); } } @@ -253,7 +356,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -264,7 +367,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientResourceLimitsAndBatchOutcomeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientResourceLimitsAndBatchOutcomeTests.cs index 39bfc70..e305ac7 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientResourceLimitsAndBatchOutcomeTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientResourceLimitsAndBatchOutcomeTests.cs @@ -6,13 +6,19 @@ using CheatEngine.Client.Dispatching; using CheatEngine.Client.Memory; using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Tests.Domains; +/// +/// Resource budgets and batch outcomes of the memory client. The batch-outcome tests carry the Q33 trait: every +/// effect category (not started, partial with its completed prefix, complete, unknown) stays observable (A24-10, +/// AX06-30). +/// public sealed class MemoryClientResourceLimitsAndBatchOutcomeTests { - private static readonly Address _address = new(0x700000); + private static readonly Address TestAddress = new(0x700000); [Fact] public void EveryDirectBudgetRejectsWorkBeforeDispatcherAdmission() @@ -20,47 +26,47 @@ public void EveryDirectBudgetRejectsWorkBeforeDispatcherAdmission() CountingDispatcher dispatcher = new(); MemoryClient byteClient = CreateClient(dispatcher, new MemoryResourceLimits(1, 1, 1, 64, 2)); - Assert.False(byteClient.TryReadBytes(new MemoryBytesReadRequest(_address, 2), out ImmutableArray bytes, + Assert.False(byteClient.TryReadBytes(new MemoryBytesReadRequest(TestAddress, 2), out ImmutableArray bytes, out CheatEngineFailure readFailure, TestContext.Current.CancellationToken)); Assert.Empty(bytes); Assert.Equal(CheatEngineFailureKind.OperationRejected, readFailure.Kind); - Assert.False(byteClient.TryWriteBytes(new MemoryBytesWriteRequest(_address, [1, 2]), + Assert.False(byteClient.TryWriteBytes(new MemoryBytesWriteRequest(TestAddress, [1, 2]), out CheatEngineFailure writeFailure, TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.OperationRejected, writeFailure.Kind); - Assert.False(byteClient.TryReadString(new MemoryStringReadRequest(_address, 1, true), out string? text, + Assert.False(byteClient.TryReadString(new MemoryStringReadRequest(TestAddress, 1, MemoryStringEncoding.Utf16), out string? text, out CheatEngineFailure stringReadFailure, TestContext.Current.CancellationToken)); Assert.Null(text); Assert.Equal(CheatEngineFailureKind.OperationRejected, stringReadFailure.Kind); - Assert.False(byteClient.TryWriteString(new MemoryStringWriteRequest(_address, "A", true), + Assert.False(byteClient.TryWriteString(new MemoryStringWriteRequest(TestAddress, "A", 1, MemoryStringEncoding.Utf16), out CheatEngineFailure stringWriteFailure, TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.OperationRejected, stringWriteFailure.Kind); Assert.Equal(0, dispatcher.InvocationCount); MemoryClient countClient = CreateClient(dispatcher, new MemoryResourceLimits(4, 4, 4, 64, 1)); MemoryPrimitiveBatchReadOutcome countOutcome = countClient.ReadPrimitiveBatchDetailed( - new MemoryPrimitiveBatchReadRequest([_address, _address + 4]), TestContext.Current.CancellationToken); + new MemoryPrimitiveBatchReadRequest([TestAddress, TestAddress + 4]), TestContext.Current.CancellationToken); Assert.Equal(MemoryBatchWriteEffectState.NotStarted, countClient.WritePrimitiveBatchDetailed( new MemoryPrimitiveBatchWriteRequest([ - new MemoryAddressValue(_address, 1), new MemoryAddressValue(_address + 4, 2) + new MemoryAddressValue(TestAddress, 1), new MemoryAddressValue(TestAddress + 4, 2) ]), TestContext.Current.CancellationToken).EffectState); Assert.Equal(0, countOutcome.CompletedCount); Assert.Null(countOutcome.FailedIndex); - Assert.Equal(CheatEngineFailureKind.OperationRejected, countOutcome.Cause?.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, countOutcome.Failure?.Kind); Assert.Equal(0, dispatcher.InvocationCount); MemoryClient payloadClient = CreateClient(dispatcher, new MemoryResourceLimits(8, 8, 8, sizeof(int), 2)); MemoryPrimitiveBatchReadOutcome payloadOutcome = payloadClient.ReadPrimitiveBatchDetailed( - new MemoryPrimitiveBatchReadRequest([_address, _address + 4]), TestContext.Current.CancellationToken); + new MemoryPrimitiveBatchReadRequest([TestAddress, TestAddress + 4]), TestContext.Current.CancellationToken); MemoryPrimitiveBatchWriteOutcome payloadWriteOutcome = payloadClient.WritePrimitiveBatchDetailed( new MemoryPrimitiveBatchWriteRequest([ - new MemoryAddressValue(_address, 1), new MemoryAddressValue(_address + 4, 2) + new MemoryAddressValue(TestAddress, 1), new MemoryAddressValue(TestAddress + 4, 2) ]), TestContext.Current.CancellationToken); Assert.Equal(0, payloadOutcome.CompletedCount); - Assert.Equal(CheatEngineFailureKind.OperationRejected, payloadOutcome.Cause?.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, payloadOutcome.Failure?.Kind); Assert.Equal(MemoryBatchWriteEffectState.NotStarted, payloadWriteOutcome.EffectState); - Assert.Equal(CheatEngineFailureKind.OperationRejected, payloadWriteOutcome.Cause?.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, payloadWriteOutcome.Failure?.Kind); Assert.Equal(0, dispatcher.InvocationCount); } @@ -71,9 +77,9 @@ public void DirectByteReadAndWriteBudgetsUseTheirIndependentlyConfiguredLimits() RejectingDispatcher dispatcher = new(dispatchFailure); MemoryClient client = CreateClient(dispatcher, new MemoryResourceLimits(2, 1, 32, 32, 2)); - Assert.False(client.TryReadBytes(new MemoryBytesReadRequest(_address, 2), out _, + Assert.False(client.TryReadBytes(new MemoryBytesReadRequest(TestAddress, 2), out _, out CheatEngineFailure readFailure, TestContext.Current.CancellationToken)); - Assert.False(client.TryWriteBytes(new MemoryBytesWriteRequest(_address, [1, 2]), + Assert.False(client.TryWriteBytes(new MemoryBytesWriteRequest(TestAddress, [1, 2]), out CheatEngineFailure writeFailure, TestContext.Current.CancellationToken)); Assert.Equal(dispatchFailure, readFailure); @@ -83,19 +89,20 @@ public void DirectByteReadAndWriteBudgetsUseTheirIndependentlyConfiguredLimits() } [Fact] + [Trait("Qualification", "Q33")] public void BatchPayloadAdmissionUsesTheActualLongElementSizeBeforeDispatch() { CountingDispatcher dispatcher = new(); MemoryClient client = CreateClient(dispatcher, new MemoryResourceLimits(32, 32, 32, 4, 2)); MemoryPrimitiveBatchWriteOutcome outcome = client.WritePrimitiveBatchDetailed( - new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(_address, 10L)]), + new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(TestAddress, 10L)]), TestContext.Current.CancellationToken); - Assert.False(outcome.Succeeded); - Assert.Equal(1, outcome.AttemptedCount); + Assert.False(outcome.IsSuccess); + Assert.Equal(1, outcome.RequestedCount); Assert.Equal(0, outcome.CompletedCount); - Assert.Equal(CheatEngineFailureKind.OperationRejected, outcome.Cause?.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, outcome.Failure?.Kind); Assert.Equal(MemoryBatchWriteEffectState.NotStarted, outcome.EffectState); Assert.Equal(0, dispatcher.InvocationCount); } @@ -105,78 +112,87 @@ public void ExactResourceBoundariesAreAdmittedToTheDispatcher() { CheatEngineFailure expected = new(CheatEngineFailureKind.InvalidState, "Test.Dispatcher", "Rejected."); MemoryClient client = CreateClient(new RejectingDispatcher(expected), new MemoryResourceLimits(2, 2, 2, 8, 2)); - MemoryPrimitiveBatchReadRequest reads = new([_address, _address + 4]); + MemoryPrimitiveBatchReadRequest reads = new([TestAddress, TestAddress + 4]); MemoryPrimitiveBatchWriteRequest writes = new([ - new MemoryAddressValue(_address, 1), new MemoryAddressValue(_address + 4, 2) + new MemoryAddressValue(TestAddress, 1), new MemoryAddressValue(TestAddress + 4, 2) ]); - Assert.False(client.TryReadBytes(new MemoryBytesReadRequest(_address, 2), out _, + Assert.False(client.TryReadBytes(new MemoryBytesReadRequest(TestAddress, 2), out _, out CheatEngineFailure byteReadFailure, TestContext.Current.CancellationToken)); - Assert.False(client.TryWriteBytes(new MemoryBytesWriteRequest(_address, [1, 2]), + Assert.False(client.TryWriteBytes(new MemoryBytesWriteRequest(TestAddress, [1, 2]), out CheatEngineFailure byteWriteFailure, TestContext.Current.CancellationToken)); - Assert.False(client.TryReadString(new MemoryStringReadRequest(_address, 1, true), out _, + Assert.False(client.TryReadString(new MemoryStringReadRequest(TestAddress, 1, MemoryStringEncoding.Utf16), out _, out CheatEngineFailure stringReadFailure, TestContext.Current.CancellationToken)); - Assert.False(client.TryWriteString(new MemoryStringWriteRequest(_address, "A", true), + Assert.False(client.TryWriteString(new MemoryStringWriteRequest(TestAddress, "A", 1, MemoryStringEncoding.Utf16), out CheatEngineFailure stringWriteFailure, TestContext.Current.CancellationToken)); Assert.Equal(expected, byteReadFailure); Assert.Equal(expected, byteWriteFailure); Assert.Equal(expected, stringReadFailure); Assert.Equal(expected, stringWriteFailure); - Assert.Equal(expected, client.ReadPrimitiveBatchDetailed(reads, TestContext.Current.CancellationToken).Cause); - Assert.Equal(expected, client.WritePrimitiveBatchDetailed(writes, TestContext.Current.CancellationToken).Cause); + Assert.Equal(expected, client.ReadPrimitiveBatchDetailed(reads, TestContext.Current.CancellationToken).Failure); + Assert.Equal(expected, client.WritePrimitiveBatchDetailed(writes, TestContext.Current.CancellationToken).Failure); } [Theory] + [Trait("Qualification", "Q33")] [InlineData(0)] [InlineData(1)] [InlineData(2)] public void DetailedReadReportsEveryHostFailureIndexAndItsImmutableCompletedPrefix(int failedIndex) { - BatchPort port = new() { ReadFailureIndex = failedIndex }; + BatchPort port = new() + { + ReadFailureIndex = failedIndex + }; MemoryClient client = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(32, 32, 32, 32, 3), port); MemoryPrimitiveBatchReadOutcome outcome = client.ReadPrimitiveBatchDetailed( - new MemoryPrimitiveBatchReadRequest([_address, _address + 4, _address + 8]), + new MemoryPrimitiveBatchReadRequest([TestAddress, TestAddress + 4, TestAddress + 8]), TestContext.Current.CancellationToken); - Assert.False(outcome.Succeeded); - Assert.Equal(3, outcome.AttemptedCount); + Assert.False(outcome.IsSuccess); + Assert.Equal(3, outcome.RequestedCount); Assert.Equal(failedIndex, outcome.CompletedCount); Assert.Equal(failedIndex, outcome.FailedIndex); - Assert.Equal(Enumerable.Range(0, failedIndex).Select(static index => 100 + index), outcome.ReadPrefix); - Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, outcome.Cause?.Kind); + Assert.Equal(Enumerable.Range(0, failedIndex).Select(static index => 100 + index), outcome.Values); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, outcome.Failure?.Kind); Assert.Equal(failedIndex + 1, port.ReadInvocationCount); } [Theory] + [Trait("Qualification", "Q33")] [InlineData(0, MemoryBatchWriteEffectState.NotStarted)] [InlineData(1, MemoryBatchWriteEffectState.Partial)] [InlineData(2, MemoryBatchWriteEffectState.Partial)] public void DetailedWriteReportsEveryHostFailureIndexWithoutRollback(int failedIndex, MemoryBatchWriteEffectState expectedEffectState) { - BatchPort port = new() { WriteFailureIndex = failedIndex }; + BatchPort port = new() + { + WriteFailureIndex = failedIndex + }; MemoryClient client = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(32, 32, 32, 32, 3), port); MemoryPrimitiveBatchWriteOutcome outcome = client.WritePrimitiveBatchDetailed( new MemoryPrimitiveBatchWriteRequest([ - new MemoryAddressValue(_address, 10), new MemoryAddressValue(_address + 4, 20), - new MemoryAddressValue(_address + 8, 30) + new MemoryAddressValue(TestAddress, 10), new MemoryAddressValue(TestAddress + 4, 20), + new MemoryAddressValue(TestAddress + 8, 30) ]), TestContext.Current.CancellationToken); - Assert.False(outcome.Succeeded); - Assert.Equal(3, outcome.AttemptedCount); + Assert.False(outcome.IsSuccess); + Assert.Equal(3, outcome.RequestedCount); Assert.Equal(failedIndex, outcome.CompletedCount); Assert.Equal(failedIndex, outcome.FailedIndex); Assert.Equal(expectedEffectState, outcome.EffectState); - Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, outcome.Cause?.Kind); - Assert.Equal(Enumerable.Range(0, failedIndex).Select(static index => 10 + index * 10), port.CommittedValues); + Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, outcome.Failure?.Kind); + Assert.Equal(Enumerable.Range(0, failedIndex).Select(static index => 10 + (index * 10)), port.CommittedValues); Assert.Equal(failedIndex + 1, port.WriteInvocationCount); } [Fact] + [Trait("Qualification", "Q33")] public void DetailedWriteReportsCompleteEffectAndLegacyWrappersPreserveTheOldFailureShape() { BatchPort successfulPort = new(); @@ -185,24 +201,28 @@ public void DetailedWriteReportsCompleteEffectAndLegacyWrappersPreserveTheOldFai successfulPort); MemoryPrimitiveBatchWriteOutcome success = successfulClient.WritePrimitiveBatchDetailed( new MemoryPrimitiveBatchWriteRequest([ - new MemoryAddressValue(_address, 10), new MemoryAddressValue(_address + 4, 20) + new MemoryAddressValue(TestAddress, 10), new MemoryAddressValue(TestAddress + 4, 20) ]), TestContext.Current.CancellationToken); - Assert.True(success.Succeeded); + Assert.True(success.IsSuccess); Assert.Equal(2, success.CompletedCount); - Assert.Equal(MemoryBatchWriteEffectState.Complete, success.EffectState); - Assert.Null(success.Cause); + Assert.Equal(MemoryBatchWriteEffectState.Completed, success.EffectState); + Assert.Null(success.Failure); Assert.Equal([10, 20], successfulPort.CommittedValues); Assert.Equal(2, successfulPort.WriteInvocationCount); Assert.Equal(1, successfulDispatcher.InvocationCount); - BatchPort failedPort = new() { ReadFailureIndex = 1, WriteFailureIndex = 1 }; + BatchPort failedPort = new() + { + ReadFailureIndex = 1, + WriteFailureIndex = 1 + }; MemoryClient failedClient = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(32, 32, 32, 32, 3), failedPort); - MemoryPrimitiveBatchReadRequest reads = new([_address, _address + 4]); + MemoryPrimitiveBatchReadRequest reads = new([TestAddress, TestAddress + 4]); MemoryPrimitiveBatchWriteRequest writes = new([ - new MemoryAddressValue(_address, 10), new MemoryAddressValue(_address + 4, 20) + new MemoryAddressValue(TestAddress, 10), new MemoryAddressValue(TestAddress + 4, 20) ]); Assert.False(failedClient.TryReadPrimitiveBatch(reads, out ImmutableArray values, @@ -215,6 +235,33 @@ public void DetailedWriteReportsCompleteEffectAndLegacyWrappersPreserveTheOldFai } [Fact] + [Trait("Qualification", "Q33")] + public void DetailedBatchOutcomesReportTheirFailureAndSuccessDirectly() + { + BatchPort port = new() + { + WriteFailureIndex = 1 + }; + MemoryClient memory = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(32, 32, 32, 32, 3), port); + + MemoryPrimitiveBatchReadOutcome read = memory.ReadPrimitiveBatchDetailed( + new MemoryPrimitiveBatchReadRequest([TestAddress, TestAddress + 4]), TestContext.Current.CancellationToken); + MemoryPrimitiveBatchWriteOutcome write = memory.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([ + new MemoryAddressValue(TestAddress, 10), new MemoryAddressValue(TestAddress + 4, 20) + ]), + TestContext.Current.CancellationToken); + + Assert.True(read.IsSuccess); + Assert.Null(read.Failure); + Assert.Equal([100, 101], read.Values); + Assert.False(write.IsSuccess); + Assert.Equal(MemoryBatchWriteEffectState.Partial, write.EffectState); + Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, write.Failure!.Value.Kind); + } + + [Fact] + [Trait("Qualification", "Q33")] public void DispatcherFailureLeavesWriteEffectUnknownAndDoesNotExposeAFailedIndex() { CheatEngineFailure expected = new(CheatEngineFailureKind.InvalidState, "Test.Dispatcher", "Rejected."); @@ -222,13 +269,13 @@ public void DispatcherFailureLeavesWriteEffectUnknownAndDoesNotExposeAFailedInde CreateClient(new RejectingDispatcher(expected), new MemoryResourceLimits(32, 32, 32, 32, 3)); MemoryPrimitiveBatchWriteOutcome outcome = client.WritePrimitiveBatchDetailed( - new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(_address, 10)]), + new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(TestAddress, 10)]), TestContext.Current.CancellationToken); - Assert.False(outcome.Succeeded); + Assert.False(outcome.IsSuccess); Assert.Equal(0, outcome.CompletedCount); Assert.Null(outcome.FailedIndex); - Assert.Equal(expected, outcome.Cause); + Assert.Equal(expected, outcome.Failure); Assert.Equal(MemoryBatchWriteEffectState.Unknown, outcome.EffectState); } @@ -241,11 +288,11 @@ public void ConstructionSnapshotsLimitsAndCodecProgrammingExceptionsStillPropaga configured.MaximumBatchOperationCount = 1; MemoryPrimitiveBatchReadOutcome outcome = client.ReadPrimitiveBatchDetailed( - new MemoryPrimitiveBatchReadRequest([_address, _address + 4]), TestContext.Current.CancellationToken); - Assert.True(outcome.Succeeded); + new MemoryPrimitiveBatchReadRequest([TestAddress, TestAddress + 4]), TestContext.Current.CancellationToken); + Assert.True(outcome.IsSuccess); Assert.Equal(2, outcome.CompletedCount); Assert.Throws(() => client.TryRead( - new MemoryReadRequest(_address, new ThrowingCodec()), + new MemoryReadRequest(TestAddress, new ThrowingCodec()), out _, out _, TestContext.Current.CancellationToken)); } @@ -255,16 +302,16 @@ public void DefaultLimitsAdmitANormalBatchAndCodecContextsStopOversizedUnknownBu BatchPort defaultPort = new(); MemoryClient defaultClient = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(), defaultPort); MemoryPrimitiveBatchReadOutcome defaultOutcome = defaultClient.ReadPrimitiveBatchDetailed( - new MemoryPrimitiveBatchReadRequest([_address, _address + 4]), TestContext.Current.CancellationToken); + new MemoryPrimitiveBatchReadRequest([TestAddress, TestAddress + 4]), TestContext.Current.CancellationToken); - Assert.True(defaultOutcome.Succeeded); - Assert.Equal([100, 101], defaultOutcome.ReadPrefix); + Assert.True(defaultOutcome.IsSuccess); + Assert.Equal([100, 101], defaultOutcome.Values); BatchPort constrainedPort = new(); MemoryClient constrainedClient = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(1, 1, 32, 32, 2), constrainedPort); - bool succeeded = constrainedClient.TryRead(new MemoryReadRequest(_address, new OversizedReadCodec()), + bool succeeded = constrainedClient.TryRead(new MemoryReadRequest(TestAddress, new OversizedReadCodec()), out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); @@ -278,7 +325,7 @@ public void CustomCodecWriteAboveTheConfiguredBudgetDoesNotReachTheRawPort() BatchPort port = new(); MemoryClient client = CreateClient(new CountingDispatcher(), new MemoryResourceLimits(2, 1, 32, 32, 2), port); - bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 42, new OversizedWriteCodec()), + bool succeeded = client.TryWrite(new MemoryWriteRequest(TestAddress, 42, new OversizedWriteCodec()), out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); @@ -293,7 +340,7 @@ private static MemoryClient CreateClient(ICheatEngineDispatcher dispatcher, Memo return new MemoryClient(dispatcher, InertCoreLifetime.Create(), port ?? new BatchPort(), limits); } - private sealed class BatchPort : IMemoryCodecContextPort + private sealed class BatchPort : TargetObservationDouble, IMemoryCodecContextPort { internal List CommittedValues { @@ -336,97 +383,100 @@ internal int WriteInvocationCount private set; } - public bool IsTarget64Bit() - { - return true; - } - - public bool TryReadBytes(Address address, Span destination, out string? failure) + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) { RawReadInvocationCount++; destination.Clear(); - failure = null; + written = destination.Length; + failure = MemoryAccessFailure.None; return true; } - public bool TryReadPrimitive(Address address, out T value, out string? failure) + public bool TryReadPrimitive(Address address, out T value, out MemoryAccessFailure failure) { int index = ReadInvocationCount++; if (index == ReadFailureIndex) { value = default!; - failure = $"Read failure {index}."; + failure = MemoryAccessFailure.ReadFailed; return false; } value = (T) (object) (100 + index); - failure = null; + failure = MemoryAccessFailure.None; return true; } - public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure) + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) { RawWriteInvocationCount++; - failure = null; + failure = MemoryAccessFailure.None; return true; } - public bool TryWritePrimitive(Address address, T value, out string? failure) + public bool TryWritePrimitive(Address address, T value, out MemoryAccessFailure failure) { int index = WriteInvocationCount++; if (index == WriteFailureIndex) { - failure = $"Write failure {index}."; + failure = MemoryAccessFailure.WriteFailed; return false; } CommittedValues.Add((int) (object) value!); - failure = null; + failure = MemoryAccessFailure.None; return true; } } private sealed class ThrowingCodec : IMemoryCodec { - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; throw new InvalidOperationException("Codec programming failures must not be mapped."); } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; throw new InvalidOperationException("Codec programming failures must not be mapped."); } } private sealed class OversizedReadCodec : IMemoryCodec { - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; Span destination = stackalloc byte[2]; - bool succeeded = context.TryReadBytes(address, destination); + bool succeeded = context.TryReadBytes(address, destination, out _); value = 0; return succeeded; } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; return false; } } private sealed class OversizedWriteCodec : IMemoryCodec { - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; value = default; return false; } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; Span source = stackalloc byte[2]; - return context.TryWriteBytes(address, source); + return context.TryWriteBytes(address, source, out _); } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs index 0f3cef6..c4d352b 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Core.Domains; @@ -5,6 +6,7 @@ using CheatEngine.Client.Dispatching; using CheatEngine.Client.Memory; using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Memory; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Tests.Domains; @@ -14,7 +16,10 @@ public sealed class MemoryClientTests [Fact] public void TypedPrimitiveReadUsesTheCallerSuppliedCodec() { - RecordingInt32Codec codec = new() { ReadValue = 1234 }; + RecordingInt32Codec codec = new() + { + ReadValue = 1234 + }; MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create()); Address address = 0x401000; @@ -50,7 +55,10 @@ public void TypedPrimitiveWriteUsesTheCallerSuppliedCodec() [Fact] public void TypedCodecFailureBecomesAClassifiedMemoryReadFailure() { - RecordingInt32Codec codec = new() { ReadSucceeds = false }; + RecordingInt32Codec codec = new() + { + ReadSucceeds = false + }; MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create()); bool succeeded = client.TryRead(new MemoryReadRequest(0x403000, codec), out int value, @@ -65,6 +73,315 @@ public void TypedCodecFailureBecomesAClassifiedMemoryReadFailure() Assert.Equal(1, codec.ReadCount); } + /// + /// A12-12: reaches Cheat Engine's readString unchanged, + /// for both encodings, because the host does not document its unit. + /// + [Theory] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(255, false)] + [InlineData(255, true)] + [InlineData(MemoryResourceLimits.DefaultMaximumStringBytes, false)] + [InlineData(MemoryResourceLimits.DefaultMaximumStringBytes / sizeof(char), true)] + public void StringReadForwardsMaximumLengthUnchanged(int maximumLength, bool wideCharacter) + { + RecordingStringPort port = new(); + MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create(), port, new MemoryResourceLimits()); + Address address = 0x404000; + + bool succeeded = client.TryReadString(new MemoryStringReadRequest(address, maximumLength, + wideCharacter ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8), + out string? value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded, failure.ToString()); + Assert.Equal(RecordingStringPort.Text, value); + (Address Address, int MaximumLength, bool WideCharacter) call = Assert.Single(port.StringReads); + Assert.Equal(address, call.Address); + Assert.Equal(maximumLength, call.MaximumLength); + Assert.Equal(wideCharacter, call.WideCharacter); + } + + [Theory] + [InlineData(8, false, true)] + [InlineData(9, false, false)] + [InlineData(4, true, true)] + [InlineData(5, true, false)] + public void StringReadAdmissionChargesUtf16TwiceTheForwardedLength(int maximumLength, bool wideCharacter, + bool admitted) + { + RecordingStringPort port = new(); + MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create(), port, + new MemoryResourceLimits(64, 64, 8, 64, 1)); + + bool succeeded = client.TryReadString(new MemoryStringReadRequest(0x405000, maximumLength, + wideCharacter ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8), + out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.Equal(admitted, succeeded); + if (admitted) + { + Assert.Equal(maximumLength, Assert.Single(port.StringReads).MaximumLength); + } + else + { + Assert.Empty(port.StringReads); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + } + } + + [Fact] + [Trait("Qualification", "Q20")] + public void DetailedByteReadReportsTheConfirmedPrefixOfAPartialRead() + { + // Cheat Engine returned three of the eight requested bytes: the prefix is reported, never confused with a read + // that copied nothing, and the Try form still publishes nothing. + ByteReadPort port = new([0x10, 0x20, 0x30], MemoryAccessFailure.PartialRead); + MemoryClient client = CreateClient(port); + MemoryBytesReadRequest request = new(0x406000, 8); + + MemoryBytesReadOutcome outcome = client.ReadBytesDetailed(request, TestContext.Current.CancellationToken); + bool succeeded = client.TryReadBytes(request, out ImmutableArray bytes, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(outcome.IsSuccess); + Assert.Equal(8, outcome.RequestedLength); + Assert.Equal(3, outcome.ConfirmedLength); + Assert.Equal([0x10, 0x20, 0x30], outcome.Bytes); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, outcome.Failure!.Value.Kind); + Assert.Equal("Memory.ReadBytes", outcome.Failure.Value.Operation); + Assert.False(succeeded); + Assert.True(bytes.IsEmpty); + Assert.Equal(outcome.Failure.Value.Kind, failure.Kind); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void DetailedByteReadKeepsTheVerifiedPrefixOfAMalformedResultWithItsOwnKind() + { + ByteReadPort port = new([0x01, 0x02], MemoryAccessFailure.InvalidResult); + + MemoryBytesReadOutcome outcome = CreateClient(port).ReadBytesDetailed(new MemoryBytesReadRequest(0x406000, 4), + TestContext.Current.CancellationToken); + + Assert.Equal([0x01, 0x02], outcome.Bytes); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, outcome.Failure!.Value.Kind); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void DetailedByteReadReturnsEveryRequestedByteOnSuccess() + { + ByteReadPort port = new([0x0A, 0x0B, 0x0C, 0x0D], MemoryAccessFailure.None); + + MemoryBytesReadOutcome outcome = CreateClient(port).ReadBytesDetailed(new MemoryBytesReadRequest(0x406000, 4), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.Null(outcome.Failure); + Assert.Equal(4, outcome.ConfirmedLength); + Assert.Equal([0x0A, 0x0B, 0x0C, 0x0D], outcome.Bytes); + } + + [Fact] + public void DetailedByteReadOverTheBudgetIsRefusedBeforeDispatchWithAnEmptyPrefix() + { + ByteReadPort port = new([0x01], MemoryAccessFailure.None); + MemoryClient client = new(new InlineDispatcher(), InertCoreLifetime.Create(), port, + new MemoryResourceLimits(2, 64, 64, 64, 1)); + + MemoryBytesReadOutcome outcome = client.ReadBytesDetailed(new MemoryBytesReadRequest(0x406000, 3), + TestContext.Current.CancellationToken); + + Assert.Equal(0, outcome.ConfirmedLength); + Assert.Equal(3, outcome.RequestedLength); + Assert.Equal(CheatEngineFailureKind.OperationRejected, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, outcome.Failure.Value.HostEffect); + Assert.Equal(0, port.Reads); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void CodecContextReportsAPartialReadAsAFailureAndKeepsNoPartialBytes() + { + ByteReadPort port = new([0x7F, 0x7F], MemoryAccessFailure.PartialRead); + PrefixCodec codec = new(); + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(0x406000, codec), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal([0, 0, 0, 0], codec.Buffer); + } + + /// + /// The context hands the codec the classified failure of its own read, and a codec that returns it is published + /// unchanged: the operation names the public call, the kind follows the host's refusal. + /// + [Fact] + [Trait("Qualification", "Q20")] + public void ACodecThatPassesOnTheContextFailureHasItPublishedUnchanged() + { + ByteReadPort port = new([0x7F, 0x7F], MemoryAccessFailure.PartialRead); + ForwardingCodec codec = new(); + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(0x406000, codec), out int value, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(0, value); + Assert.Equal(codec.ContextFailure, failure); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal("Memory.Read", failure.Operation); + } + + /// A codec's own classified failure, host effect included, is the failure of the call. + [Fact] + public void ACodecThatClassifiesItsFailureHasItPublishedUnchanged() + { + ByteReadPort port = new([0x01, 0x02, 0x03, 0x04], MemoryAccessFailure.None); + CheatEngineFailure claimed = new(CheatEngineFailureKind.InvalidHostResult, "Application.Codec", + "The value read is not a valid enumeration member.", null, CheatEngineHostEffect.Completed); + ForwardingCodec codec = new() + { + Claimed = claimed + }; + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(0x406000, codec), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(claimed, failure); + Assert.Equal(default, codec.ContextFailure); + } + + private static MemoryClient CreateClient(IMemoryCodecContextPort port) + { + return new MemoryClient(new InlineDispatcher(), InertCoreLifetime.Create(), port, new MemoryResourceLimits()); + } + + /// + /// Behaves like the counted TargetMemory.TryReadBytes: it copies its bytes as the verified prefix and reports + /// its failure, or success when it holds every requested byte. + /// + private sealed class ByteReadPort(byte[] bytes, MemoryAccessFailure failure) + : TargetObservationDouble, IMemoryCodecContextPort + { + internal int Reads + { + get; + private set; + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure hostFailure) + { + Reads++; + written = Math.Min(bytes.Length, destination.Length); + bytes.AsSpan(0, written).CopyTo(destination); + hostFailure = failure; + return failure == MemoryAccessFailure.None && written == destination.Length; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure hostFailure) + { + throw new InvalidOperationException("A byte read must not write."); + } + } + + /// + /// Reads four bytes through its context and returns the context's failure, or after a + /// successful read. + /// + private sealed class ForwardingCodec : IMemoryCodec + { + internal CheatEngineFailure Claimed + { + get; + init; + } + + internal CheatEngineFailure ContextFailure + { + get; + private set; + } + + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) + { + value = 0; + if (!context.TryReadBytes(address, new byte[sizeof(int)], out CheatEngineFailure readFailure)) + { + ContextFailure = readFailure; + failure = readFailure; + return false; + } + + failure = Claimed; + return Claimed.IsDefault; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) + { + failure = default; + return false; + } + } + + /// Reads four bytes through its context and keeps the buffer it passed. + private sealed class PrefixCodec : IMemoryCodec + { + internal byte[] Buffer + { + get; + } = new byte[sizeof(int)]; + + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) + { + failure = default; + value = 0; + return context.TryReadBytes(address, Buffer, out _); + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) + { + failure = default; + return false; + } + } + + private sealed class RecordingStringPort : TargetObservationDouble, IMemoryCodecContextPort + { + internal const string Text = "copied"; + + internal List<(Address Address, int MaximumLength, bool WideCharacter)> StringReads + { + get; + } = []; + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + throw new InvalidOperationException("A string read must not use the byte port."); + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) + { + throw new InvalidOperationException("A string read must not use the byte port."); + } + + public bool TryReadString(Address address, int maximumLength, bool wideCharacter, out string? value, + out MemoryAccessFailure failure) + { + StringReads.Add((address, maximumLength, wideCharacter)); + value = Text; + failure = MemoryAccessFailure.None; + return true; + } + } + private sealed class RecordingInt32Codec : IMemoryCodec { internal int ReadValue @@ -109,8 +426,9 @@ internal int LastWrittenValue private set; } - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; Assert.NotNull(context); ReadCount++; LastReadAddress = address; @@ -118,8 +436,9 @@ public bool TryRead(IMemoryReadContext context, Address address, out int value) return ReadSucceeds; } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; Assert.NotNull(context); WriteCount++; LastWriteAddress = address; @@ -167,7 +486,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -178,7 +497,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs index 0932898..551d39a 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryCodecContextLifetimeTests.cs @@ -6,33 +6,45 @@ using CheatEngine.Client.Dispatching; using CheatEngine.Client.Memory; using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; +using SdkPointerSize = CheatEngine.SDK.Engine.Runtime.PointerSize; + namespace CheatEngine.Client.Core.Tests.Domains; public sealed class MemoryCodecContextLifetimeTests { - private static readonly Address _address = new(0x405000); + private const string ReadOperation = "Memory.Read"; + private const string WriteOperation = "Memory.Write"; + + private static readonly Address TestAddress = new(0x405000); [Fact] public void ReadContextAllowsPointerMetadataAndBytesOnlyDuringCodecInvocation() { using ControlledCoreLifetimeContext activation = new(); using CoreLifetime lifetime = new(activation); - RecordingCodecContextPort port = new() { PointerSize = sizeof(ulong) }; + RecordingCodecContextPort port = new() + { + PointerSize = sizeof(ulong) + }; MemoryClient client = CreateClient(lifetime, port); CapturingCodec codec = new() { ReadAction = static context => { - Assert.Equal(sizeof(ulong), context.PointerSize); - Assert.Equal(sizeof(ulong), context.PointerSize); + Assert.Equal(SdkPointerSize.Bit64, context.Bitness); + Assert.Equal(SdkPointerSize.Bit64, context.Bitness); byte[] buffer = new byte[sizeof(int)]; - Assert.True(context.TryReadBytes(_address, buffer)); + Assert.True(context.TryReadBytes(TestAddress, buffer, out _)); } }; - bool succeeded = client.TryRead(new MemoryReadRequest(_address, codec), out int value, + bool succeeded = client.TryRead(new MemoryReadRequest(TestAddress, codec), out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.True(succeeded); @@ -47,19 +59,22 @@ public void WriteContextAllowsPointerMetadataAndBytesOnlyDuringCodecInvocation() { using ControlledCoreLifetimeContext activation = new(); using CoreLifetime lifetime = new(activation); - RecordingCodecContextPort port = new() { PointerSize = sizeof(uint) }; + RecordingCodecContextPort port = new() + { + PointerSize = sizeof(uint) + }; MemoryClient client = CreateClient(lifetime, port); CapturingCodec codec = new() { WriteAction = static context => { - Assert.Equal(sizeof(uint), context.PointerSize); - Assert.Equal(sizeof(uint), context.PointerSize); - Assert.True(context.TryWriteBytes(_address, [0x0A, 0x0B])); + Assert.Equal(SdkPointerSize.Bit32, context.Bitness); + Assert.Equal(SdkPointerSize.Bit32, context.Bitness); + Assert.True(context.TryWriteBytes(TestAddress, [0x0A, 0x0B], out _)); } }; - bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 456, codec), + bool succeeded = client.TryWrite(new MemoryWriteRequest(TestAddress, 456, codec), out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.True(succeeded); @@ -78,17 +93,17 @@ public void RetainedContextsExpireAfterSuccessfulReturnBeforeAnyPortCall() MemoryClient client = CreateClient(lifetime, port); CapturingCodec codec = new(); - Assert.True(client.TryRead(new MemoryReadRequest(_address, codec), out _, out _, + Assert.True(client.TryRead(new MemoryReadRequest(TestAddress, codec), out _, out _, TestContext.Current.CancellationToken)); - Assert.True(client.TryWrite(new MemoryWriteRequest(_address, 456, codec), out _, + Assert.True(client.TryWrite(new MemoryWriteRequest(TestAddress, 456, codec), out _, TestContext.Current.CancellationToken)); - IMemoryReadContext readContext = Assert.IsAssignableFrom(codec.ReadContext); - IMemoryWriteContext writeContext = Assert.IsAssignableFrom(codec.WriteContext); - AssertExpired(() => _ = readContext.PointerSize); - AssertExpired(() => readContext.TryReadBytes(_address, new byte[1])); - AssertExpired(() => _ = writeContext.PointerSize); - AssertExpired(() => writeContext.TryWriteBytes(_address, [0x0A])); + IMemoryReadContext readContext = Assert.IsType(codec.ReadContext, exactMatch: false); + IMemoryWriteContext writeContext = Assert.IsType(codec.WriteContext, exactMatch: false); + AssertExpired(ReadOperation, () => _ = readContext.Bitness); + AssertExpired(ReadOperation, () => readContext.TryReadBytes(TestAddress, new byte[1], out _)); + AssertExpired(WriteOperation, () => _ = writeContext.Bitness); + AssertExpired(WriteOperation, () => writeContext.TryWriteBytes(TestAddress, [0x0A], out _)); Assert.Equal(0, port.TotalCallCount); } @@ -99,11 +114,15 @@ public void FailedCodecsExpireCapturedContextsAndPreserveMemoryFailures() using CoreLifetime lifetime = new(activation); RecordingCodecContextPort port = new(); MemoryClient client = CreateClient(lifetime, port); - CapturingCodec codec = new() { ReadSucceeds = false, WriteSucceeds = false }; + CapturingCodec codec = new() + { + ReadSucceeds = false, + WriteSucceeds = false + }; - bool readSucceeded = client.TryRead(new MemoryReadRequest(_address, codec), out int readValue, + bool readSucceeded = client.TryRead(new MemoryReadRequest(TestAddress, codec), out int readValue, out CheatEngineFailure readFailure, TestContext.Current.CancellationToken); - bool writeSucceeded = client.TryWrite(new MemoryWriteRequest(_address, 456, codec), + bool writeSucceeded = client.TryWrite(new MemoryWriteRequest(TestAddress, 456, codec), out CheatEngineFailure writeFailure, TestContext.Current.CancellationToken); Assert.False(readSucceeded); @@ -113,8 +132,8 @@ public void FailedCodecsExpireCapturedContextsAndPreserveMemoryFailures() Assert.False(writeSucceeded); Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, writeFailure.Kind); Assert.Equal("Memory.Write", writeFailure.Operation); - AssertExpired(() => _ = codec.ReadContext!.PointerSize); - AssertExpired(() => _ = codec.WriteContext!.PointerSize); + AssertExpired(ReadOperation, () => _ = codec.ReadContext!.Bitness); + AssertExpired(WriteOperation, () => _ = codec.WriteContext!.Bitness); Assert.Equal(0, port.TotalCallCount); } @@ -127,20 +146,26 @@ public void ThrowingCodecsExpireCapturedContextsAndRethrowOriginalExceptions() MemoryClient client = CreateClient(lifetime, port); InvalidOperationException readException = new("read failure"); InvalidOperationException writeException = new("write failure"); - CapturingCodec readCodec = new() { ReadException = readException }; - CapturingCodec writeCodec = new() { WriteException = writeException }; + CapturingCodec readCodec = new() + { + ReadException = readException + }; + CapturingCodec writeCodec = new() + { + WriteException = writeException + }; InvalidOperationException actualRead = Assert.Throws(() => - client.TryRead(new MemoryReadRequest(_address, readCodec), out _, out _, + client.TryRead(new MemoryReadRequest(TestAddress, readCodec), out _, out _, TestContext.Current.CancellationToken)); InvalidOperationException actualWrite = Assert.Throws(() => - client.TryWrite(new MemoryWriteRequest(_address, 456, writeCodec), out _, + client.TryWrite(new MemoryWriteRequest(TestAddress, 456, writeCodec), out _, TestContext.Current.CancellationToken)); Assert.Same(readException, actualRead); Assert.Same(writeException, actualWrite); - AssertExpired(() => _ = readCodec.ReadContext!.PointerSize); - AssertExpired(() => _ = writeCodec.WriteContext!.PointerSize); + AssertExpired(ReadOperation, () => _ = readCodec.ReadContext!.Bitness); + AssertExpired(WriteOperation, () => _ = writeCodec.WriteContext!.Bitness); Assert.Equal(0, port.TotalCallCount); } @@ -157,9 +182,9 @@ public void WorkerThreadCannotUseContextsWhileTheirCodecInvocationsRemainActive( WriteAction = static context => AssertWorkerWriteIsRejected(context) }; - Assert.True(client.TryRead(new MemoryReadRequest(_address, codec), out _, out _, + Assert.True(client.TryRead(new MemoryReadRequest(TestAddress, codec), out _, out _, TestContext.Current.CancellationToken)); - Assert.True(client.TryWrite(new MemoryWriteRequest(_address, 456, codec), out _, + Assert.True(client.TryWrite(new MemoryWriteRequest(TestAddress, 456, codec), out _, TestContext.Current.CancellationToken)); Assert.Equal(0, port.TotalCallCount); @@ -174,12 +199,15 @@ public void ContextFromPriorInvocationCannotBeRevivedDuringALaterInvocation() MemoryClient client = CreateClient(lifetime, port); CapturingCodec firstCodec = new(); - Assert.True(client.TryRead(new MemoryReadRequest(_address, firstCodec), out _, out _, + Assert.True(client.TryRead(new MemoryReadRequest(TestAddress, firstCodec), out _, out _, TestContext.Current.CancellationToken)); - IMemoryReadContext firstContext = Assert.IsAssignableFrom(firstCodec.ReadContext); - CapturingCodec secondCodec = new() { ReadAction = _ => AssertExpired(() => ConsumePointerSize(firstContext)) }; + IMemoryReadContext firstContext = Assert.IsType(firstCodec.ReadContext, exactMatch: false); + CapturingCodec secondCodec = new() + { + ReadAction = _ => AssertExpired(ReadOperation, () => ConsumeBitness(firstContext)) + }; - Assert.True(client.TryRead(new MemoryReadRequest(_address, secondCodec), out _, out _, + Assert.True(client.TryRead(new MemoryReadRequest(TestAddress, secondCodec), out _, out _, TestContext.Current.CancellationToken)); Assert.NotSame(firstContext, secondCodec.ReadContext); Assert.Equal(0, port.TotalCallCount); @@ -198,11 +226,11 @@ public void ContextRejectsActivationEpochChangesAndAReenabledClientDoesNotRevive ReadAction = context => { oldActivation.Epoch++; - activeUseFailure = CaptureException(() => _ = context.PointerSize); + activeUseFailure = CaptureException(() => _ = context.Bitness); } }; - Assert.True(oldClient.TryRead(new MemoryReadRequest(_address, oldCodec), out _, out _, + Assert.True(oldClient.TryRead(new MemoryReadRequest(TestAddress, oldCodec), out _, out _, TestContext.Current.CancellationToken)); Assert.IsType(activeUseFailure); Assert.Equal(0, oldPort.TotalCallCount); @@ -213,29 +241,30 @@ public void ContextRejectsActivationEpochChangesAndAReenabledClientDoesNotRevive RecordingCodecContextPort newPort = new(); MemoryClient newClient = CreateClient(newLifetime, newPort); CapturingCodec newCodec = new(); - Assert.True(newClient.TryRead(new MemoryReadRequest(_address, newCodec), out _, out _, + Assert.True(newClient.TryRead(new MemoryReadRequest(TestAddress, newCodec), out _, out _, TestContext.Current.CancellationToken)); - AssertExpired(() => _ = oldCodec.ReadContext!.PointerSize); + AssertExpired(ReadOperation, () => _ = oldCodec.ReadContext!.Bitness); Assert.Equal(0, oldPort.TotalCallCount); } - private static void AssertExpired(Action operation) + /// An expired context names the public call that ran its codec: Memory.Read or Memory.Write. + private static void AssertExpired(string expectedOperation, Action operation) { CheatEngineActivationExpiredException exception = Assert.Throws(operation); - Assert.Equal("Memory.CodecContext", exception.Failure.Operation); + Assert.Equal(expectedOperation, exception.Failure.Operation); } - private static void ConsumePointerSize(IMemoryReadContext context) + private static void ConsumeBitness(IMemoryReadContext context) { - _ = context.PointerSize; + _ = context.Bitness; } private static void AssertWorkerReadIsRejected(IMemoryReadContext context) { - Exception? pointerFailure = CaptureWorkerException(() => _ = context.PointerSize); - Exception? readFailure = CaptureWorkerException(() => context.TryReadBytes(_address, new byte[1])); + Exception? pointerFailure = CaptureWorkerException(() => _ = context.Bitness); + Exception? readFailure = CaptureWorkerException(() => context.TryReadBytes(TestAddress, new byte[1], out _)); Assert.IsType(pointerFailure); Assert.IsType(readFailure); @@ -243,8 +272,8 @@ private static void AssertWorkerReadIsRejected(IMemoryReadContext context) private static void AssertWorkerWriteIsRejected(IMemoryWriteContext context) { - Exception? pointerFailure = CaptureWorkerException(() => _ = context.PointerSize); - Exception? writeFailure = CaptureWorkerException(() => context.TryWriteBytes(_address, [0x0A])); + Exception? pointerFailure = CaptureWorkerException(() => _ = context.Bitness); + Exception? writeFailure = CaptureWorkerException(() => context.TryWriteBytes(TestAddress, [0x0A], out _)); Assert.IsType(pointerFailure); Assert.IsType(writeFailure); @@ -333,8 +362,9 @@ internal int LastWriteValue private set; } - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; ReadContext = context; ReadAction?.Invoke(context); if (ReadException is not null) @@ -346,8 +376,9 @@ public bool TryRead(IMemoryReadContext context, Address address, out int value) return ReadSucceeds; } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; WriteContext = context; WriteAction?.Invoke(context); if (WriteException is not null) @@ -368,6 +399,8 @@ internal int PointerSize init; } = sizeof(ulong); + private SdkPointerSize Bitness => PointerSize == sizeof(ulong) ? SdkPointerSize.Bit64 : SdkPointerSize.Bit32; + internal int PointerSizeReadCount { get; @@ -380,32 +413,58 @@ internal int ReadBytesCallCount private set; } - internal int TotalCallCount => PointerSizeReadCount + ReadBytesCallCount + WriteBytesCallCount; - - internal int WriteBytesCallCount + /// Gets the number of target observations (each one reads every target fact). + internal int FactCallCount { get; private set; } - public bool IsTarget64Bit() + internal int TotalCallCount => FactCallCount + ReadBytesCallCount + WriteBytesCallCount; + + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) + { + FactCallCount++; + observation = new CurrentProcessObservation(new TargetProcessId(42), Bitness); + return ProcessOperationStatus.Success; + } + + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) { + FactCallCount++; PointerSizeReadCount++; - return PointerSize == sizeof(ulong); + observation = TargetObservations.Create(42, PointerSize == sizeof(ulong), configuredPointerSizeBytes: PointerSize); + return ProcessOperationStatus.Success; + } + + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out SdkPointerSize pointerSize) + { + FactCallCount++; + rawBytes = PointerSize; + pointerSize = Bitness; + return ProcessOperationStatus.Success; + } + + internal int WriteBytesCallCount + { + get; + private set; } - public bool TryReadBytes(Address address, Span destination, out string? failure) + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) { ReadBytesCallCount++; destination.Clear(); - failure = null; + written = destination.Length; + failure = MemoryAccessFailure.None; return true; } - public bool TryWriteBytes(Address address, ReadOnlySpan source, out string? failure) + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) { WriteBytesCallCount++; - failure = null; + failure = MemoryAccessFailure.None; return true; } } @@ -438,7 +497,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -449,7 +508,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryPointerWidthTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryPointerWidthTests.cs new file mode 100644 index 0000000..7e34474 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryPointerWidthTests.cs @@ -0,0 +1,826 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The codec width is the target process width (spike C3 D3(d): Cheat Engine's readPointer follows it); Cheat Engine's +/// configured pointer size is a separate fact, and the Client's own pointer-typed paths are refused when it differs +/// (audit A10-18, A12-24, A18-20, CLI-MEM-1). +/// +public sealed class MemoryPointerWidthTests +{ + private static readonly Address TestAddress = new(0x405000); + + [Fact] + [Trait("Qualification", "Q31")] + public void CodecContextReportsConfiguredPointerSizeSeparatelyFromProcessWidth() + { + PointerWidthPort port = new() + { + ConfiguredPointerSize = 4 + }; + FactCodec codec = new(); + + Assert.True(CreateClient(port).TryRead(new MemoryReadRequest(TestAddress, codec), out _, out _, + TestContext.Current.CancellationToken)); + + Assert.Equal(PointerSize.Bit64, codec.Bitness); + Assert.Equal(4, codec.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit32, codec.ConfiguredPointerSize); + Assert.True(codec.Differs); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void CodecContextPointerSizeFollowsTheTargetProcessWidthNotThePluginProcessWidth() + { + // A 32-bit target observed from a 64-bit test process: the width comes from targetIs64Bit, never IntPtr.Size. + Assert.Equal(sizeof(ulong), IntPtr.Size); + PointerWidthPort port = new() + { + Is64Bit = false, + ConfiguredPointerSize = 4 + }; + FactCodec codec = new(); + + Assert.True(CreateClient(port).TryRead(new MemoryReadRequest(TestAddress, codec), out _, out _, + TestContext.Current.CancellationToken)); + + Assert.Equal(PointerSize.Bit32, codec.Bitness); + Assert.False(codec.Differs); + } + + [Fact] + public void CodecContextWithoutASelectedTargetReportsTargetNotAttachedWithoutReadingWidthFacts() + { + // Spike C3 D2: with no target opened Cheat Engine reports x86, 64-bit and pointer size 8; nothing is read. + PointerWidthPort port = new() + { + ProcessId = 0 + }; + FactCodec codec = new(); + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(TestAddress, codec), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Memory.Read", failure.Operation); + Assert.Equal([nameof(ITargetObservationPort.ObserveTargetArchitecture)], port.TargetCalls); + Assert.Equal(0, port.ByteReads); + } + + [Theory] + [InlineData("FaultedOpenedProcessRead", CheatEngineFailureKind.LuaError)] + [InlineData("FileAsProcessSentinel", CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData("FaultedClosingProcessRead", CheatEngineFailureKind.LuaError)] + public void CodecContextReportsAnUnobservableWidthWithItsOwnKindInsteadOfNoTarget(string scenario, + CheatEngineFailureKind expected) + { + // ADR-08: only an observed "no process selected" means that no target is selected. A raising selection read or + // a file opened as a process leaves the width unobservable, with the kind of what CheatEngine.SDK reported. + PointerWidthPort port = scenario switch + { + "FaultedOpenedProcessRead" => new PointerWidthPort + { + ProcessIdFails = true + }, + "FileAsProcessSentinel" => new PointerWidthPort + { + ProcessId = 4294967295L + }, + _ => new PointerWidthPort + { + ClosingProcessIdFails = true + } + }; + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(TestAddress, new FactCodec()), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(expected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Contains("process width is unobservable", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("No target process is selected", failure.Message, StringComparison.Ordinal); + Assert.Equal(0, port.ByteReads); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void CustomCodecSeesTheProcessWidthAndTheConfiguredSizeSeparately() + { + // An application codec is never refused by the Client: it receives the facts and decides. + PointerWidthPort port = new() + { + ConfiguredPointerSize = 4, + Bytes = [1, 2, 3, 4, 5, 6, 7, 8] + }; + FactCodec codec = new() + { + ReadBytes = true + }; + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(TestAddress, codec), out int value, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(sizeof(ulong), value); + Assert.Equal(PointerSize.Bit64, codec.Bitness); + Assert.Equal(PointerSize.Bit32, codec.ConfiguredPointerSize); + Assert.Equal(1, port.ByteReads); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void ReadPrimitiveAddressIsRefusedBeforeAnyHostReadOnPointerWidthMismatch() + { + PointerWidthPort port = new() + { + ConfiguredPointerSize = 4 + }; + MemoryClient client = CreateClient(port); + + bool readSucceeded = client.TryReadPrimitive(TestAddress, out Address read, out CheatEngineFailure readFailure, + TestContext.Current.CancellationToken); + bool writeSucceeded = client.TryWritePrimitive(TestAddress, TestAddress, out CheatEngineFailure writeFailure, + TestContext.Current.CancellationToken); + bool intSucceeded = client.TryReadPrimitive(TestAddress, out int _, out _, TestContext.Current.CancellationToken); + + Assert.False(readSucceeded); + Assert.Equal(default, read); + Assert.Equal(CheatEngineFailureKind.OperationRejected, readFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, readFailure.HostEffect); + Assert.Equal("Memory.ReadPrimitive", readFailure.Operation); + Assert.Contains("Cheat Engine's readPointer follows the process width", readFailure.Message, + StringComparison.Ordinal); + Assert.False(writeSucceeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, writeFailure.Kind); + Assert.Equal("Memory.WritePrimitive", writeFailure.Operation); + Assert.Equal(0, port.PointerReads); + Assert.Equal(0, port.PointerWrites); + Assert.True(intSucceeded, "An explicit integer read is not pointer-typed and is never refused."); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void AddressPrimitiveBatchIsRefusedAsNotStartedOnPointerWidthMismatch() + { + PointerWidthPort port = new() + { + ConfiguredPointerSize = 4 + }; + MemoryClient client = CreateClient(port); + + MemoryPrimitiveBatchWriteOutcome write = client.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest
([ + new MemoryAddressValue
(TestAddress, TestAddress), new MemoryAddressValue
(TestAddress + 8, 0) + ]), TestContext.Current.CancellationToken); + MemoryPrimitiveBatchReadOutcome
read = client.ReadPrimitiveBatchDetailed( + new MemoryPrimitiveBatchReadRequest
([TestAddress, TestAddress + 8]), + TestContext.Current.CancellationToken); + + Assert.False(write.IsSuccess); + Assert.Equal(2, write.RequestedCount); + Assert.Equal(0, write.CompletedCount); + Assert.Null(write.FailedIndex); + Assert.Equal(MemoryBatchWriteEffectState.NotStarted, write.EffectState); + Assert.Equal(CheatEngineFailureKind.OperationRejected, write.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, write.Failure.Value.HostEffect); + Assert.False(read.IsSuccess); + Assert.Equal(0, read.CompletedCount); + Assert.True(read.Values.IsEmpty); + Assert.Equal(CheatEngineFailureKind.OperationRejected, read.Failure!.Value.Kind); + Assert.Equal(0, port.PointerReads); + Assert.Equal(0, port.PointerWrites); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void ResolvePointerChainIsRefusedBeforeTheFirstHopOnPointerWidthMismatch() + { + PointerWidthPort port = new() + { + ConfiguredPointerSize = 4 + }; + + bool succeeded = CreateClient(port).TryResolvePointerChain(new PointerChainRequest(TestAddress, [0x10L, 0x20L]), + out Address resolved, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, resolved); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Memory.ResolvePointerChain", failure.Operation); + Assert.Equal(0, port.PointerReads); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void ResolvePointerChainRefusesAnIntermediateAddressBeyondTheProcessWidth() + { + // A 32-bit target: 0xFFFFFFF0 + 0x20 leaves the 32-bit address space after the first hop. + PointerWidthPort port = new() + { + Is64Bit = false, + ConfiguredPointerSize = 4, + Pointers = { [TestAddress] = new Address(0xFFFFFFF0) } + }; + + bool succeeded = CreateClient(port).TryResolvePointerChain(new PointerChainRequest(TestAddress, [0x20L, 0x8L]), + out Address resolved, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, resolved); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + // The one read returned; the Client refused the address it computed, and a read leaves no target effect. + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Contains("hop 1 of 2", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("100000010", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, port.PointerReads); + Assert.Equal([PointerSize.Bit32], port.Widths); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void ResolvePointerChainRefusesABaseAddressBeyondAThirtyTwoBitTargetBeforeAnyRead() + { + PointerWidthPort port = new() + { + Is64Bit = false, + ConfiguredPointerSize = 4 + }; + + bool succeeded = CreateClient(port).TryResolvePointerChain( + new PointerChainRequest(new Address(0x1_0000_0000), [0x10L]), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, port.PointerReads); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void ResolvePointerChainReportsTheHopWhosePointerExceedsTheTargetWidth() + { + // The second hop reads a value above 4 GiB on a 32-bit target: the SDK overload refuses it and the chain names + // the hop instead of truncating the value. + PointerWidthPort port = new() + { + Is64Bit = false, + ConfiguredPointerSize = 4, + Pointers = + { + [TestAddress] = new Address(0x500000), + [new Address(0x500010)] = new Address(0x1_0000_0000) + } + }; + + bool succeeded = CreateClient(port).TryResolvePointerChain( + new PointerChainRequest(TestAddress, [0x10L, 0x8L, 0x4L]), out Address resolved, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, resolved); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Contains("hop 2 of 3", failure.Message, StringComparison.Ordinal); + Assert.Equal(2, port.PointerReads); + Assert.All(port.Widths, static width => Assert.Equal(PointerSize.Bit32, width)); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void WritingAnAddressAboveFourGibibytesToAThirtyTwoBitTargetIsRefusedBeforeCheatEngineIsCalled() + { + PointerWidthPort port = new() + { + Is64Bit = false, + ConfiguredPointerSize = 4 + }; + MemoryClient client = CreateClient(port); + + bool refused = client.TryWritePrimitive(TestAddress, new Address(0x1_0000_0000), + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + bool admitted = client.TryWritePrimitive(TestAddress, new Address(uint.MaxValue), out _, + TestContext.Current.CancellationToken); + + Assert.False(refused); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Memory.WritePrimitive", failure.Operation); + Assert.True(admitted); + Assert.Equal(new Address(uint.MaxValue), port.Pointers[TestAddress]); + Assert.Equal([PointerSize.Bit32, PointerSize.Bit32], port.Widths); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void AnAddressAboveFourGibibytesRoundTripsOnASixtyFourBitTarget() + { + // 0x7FF612345678 is a typical x64 image address; the SDK receives the observed 64-bit width both ways. + PointerWidthPort port = new(); + MemoryClient client = CreateClient(port); + Address value = new(0x7FF6_1234_5678); + + bool written = client.TryWritePrimitive(TestAddress, value, out CheatEngineFailure writeFailure, + TestContext.Current.CancellationToken); + bool read = client.TryReadPrimitive(TestAddress, out Address readBack, out CheatEngineFailure readFailure, + TestContext.Current.CancellationToken); + + Assert.True(written, writeFailure.ToString()); + Assert.True(read, readFailure.ToString()); + Assert.Equal(value, readBack); + Assert.Equal([PointerSize.Bit64, PointerSize.Bit64], port.Widths); + } + + [Fact] + [Trait("Qualification", "Q33")] + public void AnAddressBatchWriteStopsAtTheFirstValueWiderThanAThirtyTwoBitTarget() + { + PointerWidthPort port = new() + { + Is64Bit = false, + ConfiguredPointerSize = 4 + }; + + MemoryPrimitiveBatchWriteOutcome outcome = CreateClient(port).WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest
([ + new MemoryAddressValue
(TestAddress, new Address(0x401000)), + new MemoryAddressValue
(TestAddress + 4, new Address(0x1_0000_0000)), + new MemoryAddressValue
(TestAddress + 8, new Address(0x402000)) + ]), TestContext.Current.CancellationToken); + + Assert.Equal(1, outcome.CompletedCount); + Assert.Equal(1, outcome.FailedIndex); + Assert.Equal(MemoryBatchWriteEffectState.Partial, outcome.EffectState); + Assert.Equal(CheatEngineFailureKind.OperationRejected, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.Started, outcome.Failure.Value.HostEffect); + Assert.Equal(2, port.PointerWrites); + Assert.Single(port.Pointers); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData("ReadPrimitive")] + [InlineData("WritePrimitive")] + [InlineData("ReadBatch")] + [InlineData("WriteBatch")] + [InlineData("PointerChain")] + public void AnUnknownTargetBitnessRefusesEveryPointerPathBeforeAnyAccess(string path) + { + PointerWidthPort port = new() + { + UnknownBitness = true + }; + MemoryClient client = CreateClient(port); + CancellationToken token = TestContext.Current.CancellationToken; + + CheatEngineFailure failure = path switch + { + "ReadPrimitive" => Refused(client.TryReadPrimitive(TestAddress, out Address _, out CheatEngineFailure f, + token), f), + "WritePrimitive" => Refused(client.TryWritePrimitive(TestAddress, TestAddress, out CheatEngineFailure f, + token), f), + "ReadBatch" => client.ReadPrimitiveBatchDetailed(new MemoryPrimitiveBatchReadRequest
([TestAddress]), + token).Failure!.Value, + "WriteBatch" => client.WritePrimitiveBatchDetailed(new MemoryPrimitiveBatchWriteRequest
([ + new MemoryAddressValue
(TestAddress, TestAddress) + ]), token).Failure!.Value, + "PointerChain" => Refused(client.TryResolvePointerChain(new PointerChainRequest(TestAddress, [0x10L]), + out _, out CheatEngineFailure f, token), f), + _ => throw new ArgumentOutOfRangeException(nameof(path), path, null) + }; + + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, port.PointerReads + port.PointerWrites + port.ByteReads + port.ByteWrites); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void ACodecThatStopsOnAnUnknownTargetBitnessReportsTheRefusalItsContextRecorded() + { + // Core never refuses a codec: reading the context's unknown bitness records why the width is unknown, and a + // codec that then returns false with the default failure reports that refusal. Whether a codec still accesses + // memory is its own decision; this one stops, so nothing is read. + PointerWidthPort port = new() + { + UnknownBitness = true + }; + FactCodec codec = new(); + + bool succeeded = CreateClient(port).TryRead(new MemoryReadRequest(TestAddress, codec), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(PointerSize.Unknown, codec.Bitness); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + } + + [Fact] + public void AnAddressPrimitiveWithoutASelectedTargetIsRefusedAsTargetNotAttached() + { + PointerWidthPort port = new() + { + ProcessId = 0 + }; + + bool succeeded = CreateClient(port).TryReadPrimitive(TestAddress, out Address _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, port.PointerReads); + } + + [Fact] + public void UnobservableConfiguredPointerSizeDoesNotRefusePointerOperations() + { + // No evidence of a mismatch: the Client keeps the process width and proceeds. + PointerWidthPort port = new() + { + ConfiguredPointerSizeFails = true, + Pointers = { [TestAddress] = new Address(0x500000) } + }; + MemoryClient client = CreateClient(port); + + bool readSucceeded = client.TryReadPrimitive(TestAddress, out Address read, out _, + TestContext.Current.CancellationToken); + bool chainSucceeded = client.TryResolvePointerChain(new PointerChainRequest(TestAddress, [0x10L]), + out Address resolved, out _, TestContext.Current.CancellationToken); + + Assert.True(readSucceeded); + Assert.Equal(new Address(0x500000), read); + Assert.True(chainSucceeded); + Assert.Equal(new Address(0x500010), resolved); + } + + [Fact] + public void CodecContextExceptionIsReturnedAsAFailureButConsumerCodecExceptionsAreRethrown() + { + // An SDK fault while the context observes the width reaches the codec as the exact instance the context threw: + // Core converts it. An application-owned CheatEngineOperationException with the same shape is not that instance + // and is rethrown unchanged. + InvalidOperationException sdkFault = new("detached while the width was observed"); + PointerWidthPort faulting = new() + { + TargetFault = sdkFault + }; + CheatEngineOperationException applicationFault = (CheatEngineOperationException) new CheatEngineFailure( + CheatEngineFailureKind.TargetNotAttached, "Memory.Read", "application-owned").ToException(TestContext.Current.CancellationToken); + + bool succeeded = CreateClient(faulting).TryRead(new MemoryReadRequest(TestAddress, new FactCodec()), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + CheatEngineOperationException rethrown = Assert.Throws(() => + CreateClient(new PointerWidthPort()).TryRead( + new MemoryReadRequest(TestAddress, new FactCodec { Throw = applicationFault }), out _, out _, + TestContext.Current.CancellationToken)); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Same(sdkFault, failure.Exception); + Assert.Same(applicationFault, rethrown); + } + + private static MemoryClient CreateClient(PointerWidthPort port) + { + return new MemoryClient(new ThreadBoundDispatcher(), InertCoreLifetime.Create(), port); + } + + private static CheatEngineFailure Refused(bool succeeded, CheatEngineFailure failure) + { + Assert.False(succeeded); + return failure; + } + + /// Records the pointer-width facts that an application codec observes through its context. + private sealed class FactCodec : IMemoryCodec + { + internal bool ReadBytes + { + get; + init; + } + + internal Exception? Throw + { + get; + init; + } + + internal PointerSize Bitness + { + get; + private set; + } + + internal int? ConfiguredPointerSizeBytes + { + get; + private set; + } + + internal PointerSize ConfiguredPointerSize + { + get; + private set; + } + + internal bool? Differs + { + get; + private set; + } + + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) + { + failure = default; + if (Throw is not null) + { + throw Throw; + } + + Bitness = context.Bitness; + if (!Bitness.IsKnown) + { + // The context recorded why the width is unknown; returning false reports it. + value = 0; + return false; + } + + ConfiguredPointerSizeBytes = context.ConfiguredPointerSizeBytes; + ConfiguredPointerSize = context.ConfiguredPointerSize; + Differs = context.ConfiguredPointerSizeDiffersFromBitness; + if (ReadBytes && !context.TryReadBytes(address, new byte[Bitness.Bytes], out _)) + { + value = 0; + return false; + } + + value = Bitness.Bytes; + return true; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) + { + failure = default; + return true; + } + } + + /// An x64 target (PID 42) whose facts, pointers and failures are configurable. + private sealed class PointerWidthPort : TargetObservationDouble, IMemoryCodecContextPort + { + /// Sets Cheat Engine's selected PID: zero is no target, 4294967295 the file-as-process sentinel. + internal long ProcessId + { + init + { + TargetStatus = value switch + { + 0 => ProcessOperationStatus.TargetNotAttached, + 4294967295L => ProcessOperationStatus.FileAsProcessTarget, + _ => ProcessOperationStatus.Success + }; + Target = TargetObservations.Create((int) Math.Clamp(value, 1, int.MaxValue), + Target.Bitness == PointerSize.Bit64, configuredPointerSizeBytes: Target.ConfiguredPointerSizeBytes); + } + } + + internal bool Is64Bit + { + init => Target = TargetObservations.Create(Target.ProcessId.Value, value, + configuredPointerSizeBytes: Target.ConfiguredPointerSizeBytes); + } + + internal int ConfiguredPointerSize + { + init => Target = TargetObservations.Create(Target.ProcessId.Value, Target.Bitness == PointerSize.Bit64, + configuredPointerSizeBytes: value); + } + + /// Makes getPointerSize raise: the full observation narrows and the configured size stays unknown. + internal bool ConfiguredPointerSizeFails + { + init + { + if (value) + { + TargetStatus = TargetObservations.LuaFailure; + ConfiguredStatus = TargetObservations.LuaFailure; + } + } + } + + /// Makes every selected-PID read raise. + internal bool ProcessIdFails + { + init + { + if (value) + { + TargetStatus = TargetObservations.LuaFailure; + CurrentReads = [(TargetObservations.LuaFailure, 0)]; + } + } + } + + /// Makes the closing selected-PID read of the narrowed observation raise. + internal bool ClosingProcessIdFails + { + init + { + if (value) + { + TargetStatus = TargetObservations.LuaFailure; + CurrentReads = [(ProcessOperationStatus.Success, 42), (TargetObservations.LuaFailure, 0)]; + } + } + } + + internal byte[] Bytes + { + get; + init; + } = new byte[8]; + + /// Reports a selected target whose bitness Cheat Engine did not establish. + internal bool UnknownBitness + { + init + { + if (value) + { + Target = new TargetArchitectureObservation(Target.ProcessId, TargetBackend.LocalProcess, + PointerSize.Unknown, true, false, false, 0, null); + } + } + } + + internal Dictionary Pointers + { + get; + } = []; + + /// Gets the pointer width passed to every pointer read and write, in call order. + internal List Widths + { + get; + } = []; + + internal int ByteReads + { + get; + private set; + } + + internal int ByteWrites + { + get; + private set; + } + + internal int PointerReads + { + get; + private set; + } + + internal int PointerWrites + { + get; + private set; + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + ByteReads++; + Bytes.AsSpan(0, destination.Length).CopyTo(destination); + written = destination.Length; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) + { + ByteWrites++; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryReadPrimitive(Address address, out T value, out MemoryAccessFailure failure) + { + Assert.NotEqual(typeof(Address), typeof(T)); + value = default!; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWritePrimitive(Address address, T value, out MemoryAccessFailure failure) + { + Assert.NotEqual(typeof(Address), typeof(T)); + failure = MemoryAccessFailure.None; + return true; + } + + /// Behaves like TargetMemory.TryReadPointer(Address, PointerSize, …) over . + public bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, + out MemoryAccessFailure failure) + { + PointerReads++; + Widths.Add(pointerSize); + Address pointer = Pointers.GetValueOrDefault(address); + failure = !pointerSize.IsKnown + ? MemoryAccessFailure.PointerWidthUnknown + : pointerSize == PointerSize.Bit32 && pointer.Value > uint.MaxValue + ? MemoryAccessFailure.PointerValueExceedsTargetWidth + : MemoryAccessFailure.None; + value = failure == MemoryAccessFailure.None ? pointer : default; + return failure == MemoryAccessFailure.None; + } + + /// + /// Behaves like TargetMemory.TryWritePointer(Address, Address, PointerSize, …): a value wider than a + /// 32-bit target is refused before anything is stored. + /// + public bool TryWritePointer(Address address, Address value, PointerSize pointerSize, + out MemoryAccessFailure failure) + { + PointerWrites++; + Widths.Add(pointerSize); + failure = !pointerSize.IsKnown + ? MemoryAccessFailure.PointerWidthUnknown + : pointerSize == PointerSize.Bit32 && value.Value > uint.MaxValue + ? MemoryAccessFailure.PointerValueExceedsTargetWidth + : MemoryAccessFailure.None; + if (failure == MemoryAccessFailure.None) + { + Pointers[address] = value; + } + + return failure == MemoryAccessFailure.None; + } + } + + private sealed class ThreadBoundDispatcher : ICheatEngineDispatcher + { + private readonly int _mainThreadId = Environment.CurrentManagedThreadId; + + public bool IsMainThread => Environment.CurrentManagedThreadId == _mainThreadId; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(cancellationToken); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBehaviorTests.cs index f381b16..ea4b772 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBehaviorTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBehaviorTests.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Tests.TestSupport; @@ -7,6 +5,7 @@ using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Tests.Domains; @@ -14,10 +13,14 @@ namespace CheatEngine.Client.Core.Tests.Domains; public sealed class PatternScannerBehaviorTests { [Fact] + [Trait("Qualification", "Q28")] public void TryScanResolvesModuleBeforeTheGlobalScanAndAppliesModuleAndRangeAsPostFilters() { RecordingAobMatchList matches = new(["3FFF", "4000", "4010", "4020", "4100"]); - FakeAobScanPort port = new(matches) { Modules = [Module("game.exe", 0x4000, 0x100)] }; + FakeAobScanPort port = new(matches) + { + Modules = [Module("game.exe", 0x4000, 0x100)] + }; PatternScanner scanner = CreateScanner(port); AobScanRequest request = CreateRequest( new ModuleName("game.exe"), new AobScanRange(0x4010, 0x4020), 3); @@ -33,14 +36,19 @@ public void TryScanResolvesModuleBeforeTheGlobalScanAndAppliesModuleAndRangeAsPo Assert.Equal(1, port.ScanCalls); Assert.Equal(1, port.EnumerationCallsWhenScanStarted); Assert.Equal(5, matches.ItemCalls); - Assert.True(matches.IsDisposed); + Assert.True(matches.IsReleased); } [Fact] + [Trait("Qualification", "Q28")] + [Trait("Qualification", "Q29")] public void TryScanCountsOnlyPostFilteredAddressesAgainstTheMaterializationLimit() { RecordingAobMatchList matches = new(["3FFF", "4000", "4001", "40FF"]); - FakeAobScanPort port = new(matches) { Modules = [Module("game.exe", 0x4000, 0x100)] }; + FakeAobScanPort port = new(matches) + { + Modules = [Module("game.exe", 0x4000, 0x100)] + }; PatternScanner scanner = CreateScanner(port); AobScanRequest request = CreateRequest(new ModuleName("game.exe"), null, 2); @@ -52,13 +60,16 @@ public void TryScanCountsOnlyPostFilteredAddressesAgainstTheMaterializationLimit Assert.Equal([0x4000, 0x4001], result.Matches); Assert.True(result.IsTruncated); Assert.Equal(4, matches.ItemCalls); - Assert.True(matches.IsDisposed); + Assert.True(matches.IsReleased); } [Fact] public void TryScanRejectsAnUnknownModuleWithoutStartingTheGlobalScan() { - FakeAobScanPort port = new() { Modules = [Module("other.exe", 0x4000, 0x100)] }; + FakeAobScanPort port = new() + { + Modules = [Module("other.exe", 0x4000, 0x100)] + }; PatternScanner scanner = CreateScanner(port); bool succeeded = scanner.TryScan(CreateRequest(new ModuleName("game.exe"), null, 1), @@ -67,7 +78,7 @@ public void TryScanRejectsAnUnknownModuleWithoutStartingTheGlobalScan() Assert.False(succeeded); Assert.Equal(default, result); Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); - Assert.Equal("Patterns.InModule", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); Assert.Equal(1, port.EnumerationCalls); Assert.Equal(0, port.ScanCalls); } @@ -87,7 +98,7 @@ public void TryScanRejectsAnAmbiguousModuleWithoutStartingTheGlobalScan() Assert.False(succeeded); Assert.Equal(default, result); Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); - Assert.Equal("Patterns.InModule", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); Assert.Equal(1, port.EnumerationCalls); Assert.Equal(0, port.ScanCalls); } @@ -95,7 +106,10 @@ public void TryScanRejectsAnAmbiguousModuleWithoutStartingTheGlobalScan() [Fact] public void TryScanRejectsAModuleWithoutAnImageSizeBeforeStartingTheGlobalScan() { - FakeAobScanPort port = new() { Modules = [Module("game.exe", 0x4000, null)] }; + FakeAobScanPort port = new() + { + Modules = [Module("game.exe", 0x4000, null)] + }; PatternScanner scanner = CreateScanner(port); bool succeeded = scanner.TryScan(CreateRequest(new ModuleName("game.exe"), null, 1), @@ -104,14 +118,17 @@ public void TryScanRejectsAModuleWithoutAnImageSizeBeforeStartingTheGlobalScan() Assert.False(succeeded); Assert.Equal(default, result); Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Patterns.InModule", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); Assert.Equal(0, port.ScanCalls); } [Fact] public void TryScanRejectsAZeroLengthModuleBeforeStartingTheGlobalScan() { - FakeAobScanPort port = new() { Modules = [Module("game.exe", 0x4000, 0)] }; + FakeAobScanPort port = new() + { + Modules = [Module("game.exe", 0x4000, 0)] + }; PatternScanner scanner = CreateScanner(port); bool succeeded = scanner.TryScan(CreateRequest(new ModuleName("game.exe"), null, 1), @@ -120,14 +137,17 @@ public void TryScanRejectsAZeroLengthModuleBeforeStartingTheGlobalScan() Assert.False(succeeded); Assert.Equal(default, result); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Patterns.InModule", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); Assert.Equal(0, port.ScanCalls); } [Fact] public void TryScanRejectsAnInvalidModuleEnumerationCountBeforeStartingTheGlobalScan() { - FakeAobScanPort port = new() { ReportedModuleCount = 4097 }; + FakeAobScanPort port = new() + { + ReportedModuleCount = 4097 + }; PatternScanner scanner = CreateScanner(port); bool succeeded = scanner.TryScan(CreateRequest(new ModuleName("game.exe"), null, 1), @@ -136,7 +156,7 @@ public void TryScanRejectsAnInvalidModuleEnumerationCountBeforeStartingTheGlobal Assert.False(succeeded); Assert.Equal(default, result); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Patterns.InModule", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); Assert.Equal(0, port.ScanCalls); } @@ -146,7 +166,8 @@ public void TryScanCancellationDuringModuleEnumerationPreventsTheGlobalScan() using CancellationTokenSource cancellation = new(); FakeAobScanPort port = new() { - Modules = [Module("game.exe", 0x4000, 0x100)], OnEnumerateModules = cancellation.Cancel + Modules = [Module("game.exe", 0x4000, 0x100)], + OnEnumerateModules = cancellation.Cancel }; PatternScanner scanner = CreateScanner(port); @@ -180,11 +201,15 @@ public void TryScanObservesCancellationAtTheBeginningOfTheDispatchedCallback() } [Fact] - public void TryScanObservesCancellationAfterTheGlobalScanAndDisposesTheOwnedList() + [Trait("Qualification", "Q29")] + public void TryScanObservesCancellationAfterTheGlobalScanAndReleasesTheOwnedList() { using CancellationTokenSource cancellation = new(); RecordingAobMatchList matches = new(["400000"]); - FakeAobScanPort port = new(matches) { OnScan = cancellation.Cancel }; + FakeAobScanPort port = new(matches) + { + OnScan = cancellation.Cancel + }; PatternScanner scanner = CreateScanner(port); bool succeeded = scanner.TryScan(CreateRequest(null, null, 1), @@ -197,11 +222,12 @@ public void TryScanObservesCancellationAfterTheGlobalScanAndDisposesTheOwnedList Assert.Equal(1, port.ScanCalls); Assert.Equal(0, matches.CountCalls); Assert.Equal(0, matches.ItemCalls); - Assert.True(matches.IsDisposed); + Assert.True(matches.IsReleased); } [Fact] - public void TryScanObservesCancellationDuringCopyDisposesTheOwnedListAndDoesNotPublishAPrefix() + [Trait("Qualification", "Q29")] + public void TryScanObservesCancellationDuringCopyReleasesTheOwnedListAndDoesNotPublishAPrefix() { using CancellationTokenSource cancellation = new(); RecordingAobMatchList matches = new(["400000", "400001", "400002"]) @@ -226,141 +252,457 @@ public void TryScanObservesCancellationDuringCopyDisposesTheOwnedListAndDoesNotP Assert.Equal("Patterns.Scan", failure.Operation); Assert.Equal(1, port.ScanCalls); Assert.Equal(2, matches.ItemCalls); - Assert.True(matches.IsDisposed); + Assert.True(matches.IsReleased); } - private static PatternScanner CreateScanner(FakeAobScanPort port, IMainThreadInvoker? mainThread = null) + /// + /// The throwing form raises the cancellation exception, keeps the completed host effect, and still releases the + /// owned list without publishing a prefix. + /// + [Fact] + [Trait("Qualification", "Q29")] + public void ScanThrowsOperationCanceledExceptionWhenCancellationIsObservedDuringCopy() { - return new PatternScanner( - new SdkMainThreadDispatcher(InertCoreLifetime.Create(), mainThread ?? new InlineMainThreadInvoker()), port); + using CancellationTokenSource cancellation = new(); + RecordingAobMatchList matches = new(["400000", "400001", "400002"]) + { + OnTryGetItem = index => + { + if (index == 1) + { + cancellation.Cancel(); + } + } + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + CheatEngineOperationCanceledException exception = Assert.Throws(() => + scanner.Scan(CreateRequest(null, null, 3), cancellation.Token)); + + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, exception.Failure.HostEffect); + Assert.Equal("Patterns.Scan", exception.Failure.Operation); + Assert.True(matches.IsReleased); } - private static AobScanRequest CreateRequest(ModuleName? module, AobScanRange? range, int maximumResults) + [Fact] + [Trait("Qualification", "Q28")] + public void ScanDetailedReportsHostExaminedFilteredAndMaterializedCountsForAModuleFilter() { - return new AobScanRequest(new AobPattern("90"), AobScanOptions.Default, maximumResults, module, range); + RecordingAobMatchList matches = new(["3000", "4000", "4010", "5000", "6000"]); + FakeAobScanPort port = new(matches) + { + Modules = [Module("game.exe", 0x4000, 0x100)] + }; + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(CreateRequest(new ModuleName("game.exe"), null, 10), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.Null(outcome.Failure); + Assert.Equal([0x4000, 0x4010], outcome.Result!.Value.Matches); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(5UL, metrics.HostResultCount); + Assert.Equal(5UL, metrics.ExaminedCount); + Assert.Equal(3UL, metrics.FilteredOutCount); + Assert.Equal(2, metrics.MaterializedCount); + Assert.Equal(PatternScanScope.GlobalHostScanWithManagedFilter, metrics.Scope); + Assert.True(metrics.HostScanElapsed >= TimeSpan.Zero); + Assert.True(metrics.MaterializationElapsed >= TimeSpan.Zero); + Assert.Equal(1, matches.ReleaseCount); } - private static ModuleInfo Module(string name, ulong baseAddress, ulong? imageSize) + [Fact] + [Trait("Qualification", "Q29")] + public void ScanDetailedReportsExaminedBelowHostCountWhenMaterializationStopsEarly() { - MemorySize? size = imageSize.HasValue ? new MemorySize(imageSize.Value) : null; - return new ModuleInfo(name, new Address(baseAddress), size, true, name); + RecordingAobMatchList matches = new(["400000", "400010", "400020", "400030", "400040"]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + PatternScanOutcome outcome = scanner.ScanDetailed(CreateRequest(null, null, 1), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.True(outcome.Result!.Value.IsTruncated); + Assert.Equal([0x400000], outcome.Result.Value.Matches); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(5UL, metrics.HostResultCount); + Assert.Equal(2UL, metrics.ExaminedCount); + Assert.True(metrics.ExaminedCount < metrics.HostResultCount); + Assert.Equal(0UL, metrics.FilteredOutCount); + Assert.Equal(1, metrics.MaterializedCount); + Assert.Equal(2, matches.ItemCalls); + Assert.Equal(1, matches.ReleaseCount); } - private sealed class FakeAobScanPort(RecordingAobMatchList? matchList = null) : IAobScanPort + [Fact] + [Trait("Qualification", "Q29")] + public void ScanDetailedKeepsTheMetricsOfACopyCancelledAfterTheScan() { - internal ModuleInfo[] Modules + using CancellationTokenSource cancellation = new(); + RecordingAobMatchList matches = new(["400000", "400001", "400002"]) { - get; - init; - } = []; + OnTryGetItem = index => + { + if (index == 1) + { + cancellation.Cancel(); + } + } + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + PatternScanOutcome outcome = scanner.ScanDetailed(CreateRequest(null, null, 3), cancellation.Token); + + Assert.False(outcome.IsSuccess); + Assert.Null(outcome.Result); + Assert.Equal(CheatEngineFailureKind.Cancelled, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, outcome.Failure.Value.HostEffect); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(3UL, metrics.HostResultCount); + Assert.Equal(2UL, metrics.ExaminedCount); + Assert.Equal(1, matches.ReleaseCount); + } - internal int? ReportedModuleCount + [Theory] + [InlineData(ClassificationPath.Success)] + [InlineData(ClassificationPath.NoList)] + [InlineData(ClassificationPath.InvalidList)] + [InlineData(ClassificationPath.InvalidCount)] + [InlineData(ClassificationPath.InvalidItem)] + [InlineData(ClassificationPath.CancellationAfterScan)] + public void ScanDetailedClassifiesExactlyLikeTryScan(ClassificationPath path) + { + CancellationTokenSource tryCancellation = new(); + CancellationTokenSource detailedCancellation = new(); + using (tryCancellation) + using (detailedCancellation) { - get; - init; - } + PatternScanner tryScanner = CreateScanner(CreatePort(path, tryCancellation)); + PatternScanner detailedScanner = CreateScanner(CreatePort(path, detailedCancellation)); - internal Action? OnScan - { - get; - init; - } + bool succeeded = tryScanner.TryScan(CreateRequest(null, null, 2), out AobScanResult result, + out CheatEngineFailure failure, tryCancellation.Token); + PatternScanOutcome outcome = detailedScanner.ScanDetailed(CreateRequest(null, null, 2), + detailedCancellation.Token); - internal Action? OnEnumerateModules - { - get; - init; + Assert.Equal(succeeded, outcome.IsSuccess); + Assert.Equal(path == ClassificationPath.Success, succeeded); + if (succeeded) + { + Assert.Equal(result.Matches, outcome.Result!.Value.Matches); + Assert.Equal(result.IsTruncated, outcome.Result.Value.IsTruncated); + Assert.NotNull(outcome.Metrics); + } + else + { + CheatEngineFailure detailed = Assert.NotNull(outcome.Failure); + Assert.Equal(failure.Kind, detailed.Kind); + Assert.Equal(failure.Operation, detailed.Operation); + Assert.Equal(failure.Message, detailed.Message); + Assert.Equal(failure.HostEffect, detailed.HostEffect); + Assert.Equal(path is ClassificationPath.NoList or ClassificationPath.InvalidList + or ClassificationPath.InvalidCount or ClassificationPath.CancellationAfterScan, outcome.Metrics is null); + } } - internal int EnumerationCalls + static FakeAobScanPort CreatePort(ClassificationPath path, CancellationTokenSource cancellation) { - get; - private set; + return path switch + { + ClassificationPath.NoList => new FakeAobScanPort { Outcome = AobHosts.Outcome(AobScanOutcomeKind.NoResult) }, + ClassificationPath.InvalidList => new FakeAobScanPort(), + ClassificationPath.InvalidCount => new FakeAobScanPort( + new RecordingAobMatchList(["400000"]) { CountAvailable = false }), + ClassificationPath.InvalidItem => new FakeAobScanPort(new RecordingAobMatchList(["zz"])), + ClassificationPath.CancellationAfterScan => new FakeAobScanPort(new RecordingAobMatchList(["400000"])) + { + OnScan = cancellation.Cancel + }, + _ => new FakeAobScanPort(new RecordingAobMatchList(["400000", "400010", "400020"])) + }; } + } - internal int ScanCalls - { - get; - private set; - } + [Fact] + [Trait("Qualification", "Q28")] + public void FirstOrNoneReturnsTheFirstHostListElementEvenWhenALowerAddressFollows() + { + RecordingAobMatchList matches = new(["5000", "4000"]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); - internal int? EnumerationCallsWhenScanStarted - { - get; - private set; - } + bool succeeded = scanner.TryScan(CreateRequest(null, null, 1), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); - public AobScanHostStatus TryScan(string pattern, AobScanOptions options, - [NotNullWhen(true)] out IAobMatchList? matches) - { - ScanCalls++; - EnumerationCallsWhenScanStarted ??= EnumerationCalls; - OnScan?.Invoke(); - matches = matchList; - return matchList is null ? AobScanHostStatus.InvalidResult : AobScanHostStatus.Success; - } + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal([0x5000], result.Matches); + Assert.True(result.IsTruncated); + } - public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + [Fact] + public void TryScanParsesUnpaddedX64AndZeroPaddedX86AddressFormats() + { + RecordingAobMatchList matches = new(["7FFC7A0A0000", "100000000", "00400000"]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 3), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal([0x7FFC7A0A0000, 0x100000000, 0x400000], result.Matches); + } + + public enum ClassificationPath + { + Success, + NoList, + InvalidList, + InvalidCount, + InvalidItem, + CancellationAfterScan + } + + [Fact] + [Trait("Qualification", "Q27")] + public void MissingAobResultListIsAmbiguousAndNotNotFound() + { + FakeAobScanPort port = new() { - EnumerationCalls++; - OnEnumerateModules?.Invoke(); - Array.Copy(Modules, destination, Math.Min(Modules.Length, destination.Length)); - written = ReportedModuleCount ?? Modules.Length; - return InspectionStatus.Success; - } + Outcome = AobHosts.Outcome(AobScanOutcomeKind.NoResult) + }; + PatternScanner scanner = CreateScanner(port); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 1), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.NotEqual(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.NotEqual(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal("CE AOBScan returned nil: on CE 7.7 zero matches and host failures share this shape", + failure.Message); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Null(failure.Exception); + Assert.Equal(1, port.ScanCalls); } - private sealed class RecordingAobMatchList(IReadOnlyList items) : IAobMatchList + [Fact] + [Trait("Qualification", "Q27")] + public void ScanThrowsWhenTheAobResultIsAmbiguousAndNotNotFound() { - internal Action? OnTryGetItem + PatternScanner scanner = CreateScanner(new FakeAobScanPort { - get; - init; - } + Outcome = AobHosts.Outcome(AobScanOutcomeKind.NoResult) + }); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Scan(CreateRequest(null, null, 2), TestContext.Current.CancellationToken)); - internal int CountCalls + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, exception.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, exception.Failure.HostEffect); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void InvalidResultListRemainsAnInvalidHostResult() + { + FakeAobScanPort port = new(); + PatternScanner scanner = CreateScanner(port); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 1), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(1, port.ScanCalls); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void AnEmptyReturnedListIsARealNoMatchSuccess() + { + RecordingAobMatchList matches = new([]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 1), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Empty(result.Matches); + Assert.False(result.IsTruncated); + Assert.Equal(1, matches.ReleaseCount); + } + + [Fact] + public void TryScanReportsCleanupUnconfirmedWhenTheResultListReleaseIsNotConfirmed() + { + RecordingAobMatchList matches = new(["400000", "400010"]) { - get; - private set; - } + ReleaseStatus = TargetReleaseStatus.UnconfirmedAfterInvocation + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 5), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal( + "The AOB result list release was not confirmed (CleanupUnconfirmed); copied results were discarded.", + failure.Message); + Assert.Null(failure.Exception); + Assert.Equal(1, matches.ReleaseCount); + } - internal int ItemCalls + [Fact] + public void TryScanKeepsThePrimaryFailureWhenTheReleaseIsAlsoNotConfirmed() + { + RecordingAobMatchList matches = new(["not-an-address"]) { - get; - private set; - } + ReleaseStatus = TargetReleaseStatus.RefusedRuntimeChanged + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 5), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); - internal bool IsDisposed + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal( + "AOB result 0 was not a hexadecimal address. The AOB result list release was not confirmed " + + "(RefusedRuntimeChanged).", failure.Message); + Assert.Equal(1, matches.ReleaseCount); + } + + [Fact] + public void AReleaseThatBreaksTheNoThrowContractIsAnUnconfirmedReleaseAndNeverCrossesTryScan() + { + InvalidOperationException releaseFault = new("a port that breaks the ReleaseWithOutcome contract"); + RecordingAobMatchList matches = new(["400000"]) { - get; - private set; - } + ReleaseFailure = releaseFault + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + bool succeeded = scanner.TryScan(CreateRequest(null, null, 1), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Same(releaseFault, failure.Exception); + Assert.Contains("(Unknown)", failure.Message, StringComparison.Ordinal); + Assert.Equal(1, matches.ReleaseCount); + } - public bool TryGetCount(out int count) + [Fact] + public void ScanNeverLetsAnUnconfirmedReleaseReadAsASuccess() + { + RecordingAobMatchList matches = new(["400000"]) { - CountCalls++; - count = items.Count; - return true; - } + ReleaseStatus = TargetReleaseStatus.NotInvoked + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Scan(CreateRequest(null, null, 1), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, exception.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, exception.Failure.HostEffect); + Assert.Equal(1, matches.ReleaseCount); + } - public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(ReleasePath.Success, CheatEngineFailureKind.Unknown)] + [InlineData(ReleasePath.Truncation, CheatEngineFailureKind.Unknown)] + [InlineData(ReleasePath.CancellationAfterScan, CheatEngineFailureKind.Cancelled)] + [InlineData(ReleasePath.CancellationDuringCopy, CheatEngineFailureKind.Cancelled)] + [InlineData(ReleasePath.InvalidCount, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(ReleasePath.UnavailableCount, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(ReleasePath.InvalidItem, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(ReleasePath.InvalidListStatus, CheatEngineFailureKind.InvalidHostResult)] + public void TryScanReleasesTheListExactlyOnceOnEveryPath(ReleasePath path, CheatEngineFailureKind expectedKind) + { + using CancellationTokenSource cancellation = new(); + RecordingAobMatchList matches = path switch { - ItemCalls++; - OnTryGetItem?.Invoke(index); - if ((uint) index >= items.Count) + ReleasePath.InvalidCount => new RecordingAobMatchList(["400000"]) { ReportedCount = -1 }, + ReleasePath.UnavailableCount => new RecordingAobMatchList(["400000"]) { CountAvailable = false }, + ReleasePath.InvalidItem => new RecordingAobMatchList(["400000", "not-an-address"]), + ReleasePath.CancellationDuringCopy => new RecordingAobMatchList(["400000", "400001", "400002"]) { - value = null; - return false; - } + OnTryGetItem = index => + { + if (index == 1) + { + cancellation.Cancel(); + } + } + }, + _ => new RecordingAobMatchList(["400000", "400001", "400002"]) + }; + FakeAobScanPort port = new(matches) + { + OnScan = path == ReleasePath.CancellationAfterScan ? cancellation.Cancel : null, + Outcome = path == ReleasePath.InvalidListStatus ? AobHosts.Outcome(AobScanOutcomeKind.InvalidResult) : null + }; + PatternScanner scanner = CreateScanner(port); + int invokingThread = Environment.CurrentManagedThreadId; + bool expectedSuccess = path is ReleasePath.Success or ReleasePath.Truncation; - value = items[index]; - return true; - } + bool succeeded = scanner.TryScan(CreateRequest(null, null, path == ReleasePath.Truncation ? 1 : 3), + out AobScanResult result, out CheatEngineFailure failure, cancellation.Token); - public void Dispose() - { - IsDisposed = true; - } + Assert.Equal(expectedSuccess, succeeded); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(path == ReleasePath.Truncation, result.IsTruncated); + Assert.Equal(expectedSuccess ? 0 : 1, string.IsNullOrEmpty(failure.Operation) ? 0 : 1); + Assert.Equal(1, matches.ReleaseCount); + Assert.Equal(invokingThread, matches.ReleaseThreadId); + } + + public enum ReleasePath + { + Success, + Truncation, + CancellationAfterScan, + CancellationDuringCopy, + InvalidCount, + UnavailableCount, + InvalidItem, + InvalidListStatus + } + + private static PatternScanner CreateScanner(FakeAobScanPort port, IMainThreadInvoker? mainThread = null) + { + return new PatternScanner( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), mainThread ?? new InlineMainThreadInvoker()), port); + } + + private static AobScanRequest CreateRequest(ModuleName? module, AobScanRange? range, int maximumResults) + { + return new AobScanRequest(new AobPattern("90"), maximumResults, module, range); + } + + private static ModuleInfo Module(string name, ulong baseAddress, ulong? imageSize) + { + MemorySize? size = imageSize.HasValue ? new MemorySize(imageSize.Value) : null; + return new ModuleInfo(name, new Address(baseAddress), size, true, name); } private sealed class CancellingMainThreadInvoker(CancellationTokenSource cancellation) : IMainThreadInvoker diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBoundedRouteTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBoundedRouteTests.cs new file mode 100644 index 0000000..14cd10c --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerBoundedRouteTests.cs @@ -0,0 +1,895 @@ +using System.Globalization; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// A module or range request on a qualified target runs the SDK's bounded, exhaustive route (audit F07, SDK2-05): +/// its bounds, its destination, its factual zero, its truncation, its fallback and its cancellation points. +/// +public sealed class PatternScannerBoundedRouteTests +{ + private const ulong ModuleBase = 0x4000; + private const ulong ModuleSize = 0x100; + + public static TheoryData Fallbacks => + [ + "UnqualifiedTarget", + "FileAsProcess", + "SessionCreationFailed", + "TargetIdentityUnavailable" + ]; + + [Fact] + [Trait("Qualification", "Q28")] + public void AModuleRequestOnAQualifiedTargetScansOnlyTheModule() + { + FakeAobScanPort port = QualifiedPort([0x4010, 0x4020]); + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 10), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.Equal([0x4010, 0x4020], outcome.Result!.Value.Matches); + Assert.False(outcome.Result.Value.IsTruncated); + Assert.Equal(new Address(ModuleBase), port.LastBounds!.Value.Start); + Assert.Equal(new Address(ModuleBase + ModuleSize), port.LastBounds.Value.Stop); + Assert.Equal(1, port.BoundedCalls); + Assert.Equal(0, port.ScanCalls); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(PatternScanScope.HostBoundedRange, metrics.Scope); + Assert.Equal(2UL, metrics.HostResultCount); + Assert.Equal(2, metrics.MaterializedCount); + } + + /// The bounded route reports a factual zero: the empty success the global route can never give. + [Fact] + [Trait("Qualification", "Q28")] + public void ABoundedNoMatchWithReadableErrorTextIsAFactualEmptySuccess() + { + PatternScanner scanner = CreateScanner(QualifiedPort([])); + + bool succeeded = scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded, failure.Message); + Assert.Empty(result.Matches); + Assert.False(result.IsTruncated); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void ABoundedNoMatchWithUnreadableErrorTextIsIndeterminate() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.NoMatches) with + { + IsHostErrorTextUnreadable = true + }; + PatternScanner scanner = CreateScanner(QualifiedPort([], bounded)); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 1), + TestContext.Current.CancellationToken); + + Assert.False(outcome.IsSuccess); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, outcome.Failure.Value.HostEffect); + Assert.Equal(PatternScanScope.HostBoundedRange, Assert.NotNull(outcome.Metrics).Scope); + } + + [Theory] + [InlineData(false, "Cheat Engine reported an error for the bounded AOB scan: scan failed")] + [InlineData(true, "Cheat Engine reported an error for the bounded AOB scan: scan failed (truncated)")] + public void AHostReportedErrorIsARejectionCarryingTheBoundedText(bool truncated, string expectedMessage) + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.HostReportedError) with + { + HostErrorText = "scan failed", + IsHostErrorTextTruncated = truncated + }; + PatternScanner scanner = CreateScanner(QualifiedPort([], bounded)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(expectedMessage, failure.Message); + } + + /// The inclusive range end becomes the exclusive stop: the last allowed match must fit below it. + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(0x1000UL, 0x1FFFUL, "90 90 90", 0x1000UL, 0x2002UL)] + [InlineData(0xFFFF_FFFF_FFFF_FFF0UL, 0xFFFF_FFFF_FFFF_FFFEUL, "90 90 90 90", 0xFFFF_FFFF_FFFF_FFF0UL, + ulong.MaxValue)] + [InlineData(0xFFFF_FFFF_FFFF_FFF0UL, ulong.MaxValue, "90", 0xFFFF_FFFF_FFFF_FFF0UL, ulong.MaxValue)] + public void TheRangeEndBecomesAStopThatSaturatesAtTheTopOfTheAddressSpace(ulong start, ulong end, string pattern, + ulong expectedStart, ulong expectedStop) + { + FakeAobScanPort port = QualifiedPort([]); + PatternScanner scanner = CreateScanner(port); + AobScanRequest request = new(new AobPattern(pattern), 1, null, + new AobScanRange(start, end)); + + Assert.True(scanner.TryScan(request, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken), failure.Message); + + Assert.Equal(new Address(expectedStart), port.LastBounds!.Value.Start); + Assert.Equal(new Address(expectedStop), port.LastBounds.Value.Stop); + Assert.Equal(0, port.EnumerationCalls); + } + + [Fact] + public void ARangeAtTheLastAddressLeavesNoBoundsAndIsRefusedBeforeAnyScan() + { + FakeAobScanPort port = QualifiedPort([]); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(null, new AobScanRange(ulong.MaxValue, ulong.MaxValue), 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, port.BoundedCalls); + Assert.Equal(0, port.ScanCalls); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TheBoundsAreTheModuleIntersectedWithTheRange() + { + FakeAobScanPort port = QualifiedPort([0x4040]); + PatternScanner scanner = CreateScanner(port); + + Assert.True(scanner.TryScan(Request(new ModuleName("game.exe"), new AobScanRange(0x3000, 0x4050), 2), + out AobScanResult result, out CheatEngineFailure failure, TestContext.Current.CancellationToken), + failure.Message); + + Assert.Equal([0x4040], result.Matches); + Assert.Equal(new Address(ModuleBase), port.LastBounds!.Value.Start); + Assert.Equal(new Address(0x4052), port.LastBounds.Value.Stop); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ARangeOutsideTheModuleIsRefusedBeforeAnyScanOnBothRoutes(bool qualified) + { + FakeAobScanPort port = new(new RecordingAobMatchList(["4010"])) + { + Modules = [Module()], + Selection = qualified ? AobHosts.Local() : default + }; + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), new AobScanRange(0x9000, 0x9FFF), 1), + out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("The AOB range leaves no room for a whole match inside the requested module.", failure.Message); + Assert.Equal(0, port.SelectionCalls); + Assert.Equal(0, port.BoundedCalls); + Assert.Equal(0, port.ScanCalls); + } + + /// + /// A range that ends fewer than pattern-length bytes before the module overlaps the module's bytes but allows no + /// match start whose whole match fits inside it, so it is refused before any scan, like a module smaller than the + /// pattern. + /// + [Theory] + [InlineData(true, "Range", "The AOB range leaves no room for a whole match inside the requested module.")] + [InlineData(false, "Range", "The AOB range leaves no room for a whole match inside the requested module.")] + [InlineData(true, "SmallModule", "The requested module is smaller than the AOB pattern.")] + [InlineData(false, "SmallModule", "The requested module is smaller than the AOB pattern.")] + public void BoundsThatCannotHoldAWholeMatchAreRefusedBeforeAnyScanOnBothRoutes(bool qualified, string shape, + string expectedMessage) + { + ModuleInfo module = shape == "SmallModule" + ? new ModuleInfo("game.exe", new Address(ModuleBase), new MemorySize(3), true, "game.exe") + : Module(); + FakeAobScanPort port = new(new RecordingAobMatchList(["3FFF"])) + { + Modules = [module], + Selection = qualified ? AobHosts.Local() : default + }; + PatternScanner scanner = CreateScanner(port); + AobScanRequest request = shape == "SmallModule" + ? new AobScanRequest(new AobPattern("90 90 90 90"), 1, new ModuleName("game.exe")) + : Request(new ModuleName("game.exe"), new AobScanRange(0x3000, ModuleBase - 1), 1); + + Assert.False(scanner.TryScan(request, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(expectedMessage, failure.Message); + Assert.Equal(0, port.BoundedCalls); + Assert.Equal(0, port.ScanCalls); + } + + /// + /// Deviation 9 settled: the same module request gives the same addresses on both routes. A match must lie entirely + /// inside the module, so a match that straddles the module end is dropped on the fallback route by the managed + /// filter, and on the bounded route by the same Client check even if Cheat Engine reported it (the SDK drops rows by + /// their start only). + /// + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(false)] + [InlineData(true)] + public void BothRoutesKeepOnlyMatchesThatLieEntirelyInsideTheModule(bool withRange) + { + // A 4-byte pattern in the module [0x4000, 0x4100): 0x40FC is its last whole match, 0x40FD and 0x40FF straddle + // the module end, and 0x4100 starts after it. + string[] hostRows = ["40FC", "40FD", "40FF", "4100"]; + AobScanRequest request = new(new AobPattern("90 90 90 90"), 10, new ModuleName("game.exe"), + withRange ? new AobScanRange(0x40F0, 0x40FE) : null); + + PatternScanOutcome bounded = Scan(AobHosts.Local()); + PatternScanOutcome global = Scan(AobHosts.Remote); + + Assert.True(bounded.IsSuccess, bounded.Failure?.Message); + Assert.True(global.IsSuccess, global.Failure?.Message); + Assert.Equal(PatternScanScope.HostBoundedRange, Assert.NotNull(bounded.Metrics).Scope); + Assert.Equal(PatternScanScope.GlobalHostScanWithManagedFilter, Assert.NotNull(global.Metrics).Scope); + Assert.Equal([0x40FC], bounded.Result!.Value.Matches); + Assert.Equal(bounded.Result.Value.Matches, global.Result!.Value.Matches); + Assert.Equal(bounded.Result.Value.IsTruncated, global.Result.Value.IsTruncated); + Assert.Equal(3UL, bounded.Metrics.Value.FilteredOutCount); + Assert.Equal(3UL, global.Metrics.Value.FilteredOutCount); + + PatternScanOutcome Scan(TargetSelectionFacts selection) + { + FakeAobScanPort port = new(new RecordingAobMatchList(hostRows)) + { + Modules = [Module()], + Selection = selection, + BoundedRows = [.. hostRows.Select(static row => Address.Parse(row))] + }; + return CreateScanner(port).ScanDetailed(request, TestContext.Current.CancellationToken); + } + } + + /// Both routes copy at most the Client's cap, so a request above it is truncated the same way on each. + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(true)] + [InlineData(false)] + public void BothRoutesCopyAtMostTheClientCapAndReportTheCutAsTruncation(bool qualified) + { + const int rows = ScanResourceLimits.MaximumPatternMatches; + ModuleInfo module = new("game.exe", new Address(ModuleBase), new MemorySize(0x10_0000), true, "game.exe"); + Address[] found = [.. Enumerable.Range(0, rows).Select(static index => new Address(ModuleBase + (ulong) index))]; + FakeAobScanPort port = new(new RecordingAobMatchList( + [.. found.Select(static address => address.Value.ToString("X", CultureInfo.InvariantCulture))])) + { + Modules = [module], + Selection = qualified ? AobHosts.Local() : AobHosts.Remote, + BoundedRows = found + }; + PatternScanner scanner = CreateScanner(port); + + Assert.True(scanner.TryScan(Request(new ModuleName("game.exe"), null, 100_000), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken), failure.Message); + + Assert.Equal(ScanResourceLimits.MaximumPatternMatches - 1, result.Matches.Length); + Assert.True(result.IsTruncated); + Assert.Equal(qualified ? 1 : 0, port.BoundedCalls); + } + + /// One slot more than the limit proves truncation; the Client caps the destination. + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(1, 2, 3, true)] + [InlineData(2, 3, 3, true)] + [InlineData(3, 4, 3, false)] + [InlineData(int.MaxValue, ScanResourceLimits.MaximumPatternMatches, 3, false)] + public void TheDestinationHoldsOneMoreThanTheLimitSoTruncationIsProvable(int maximumResults, + int expectedDestination, int rows, bool expectedTruncation) + { + Address[] found = [0x4010, 0x4020, 0x4030]; + FakeAobScanPort port = QualifiedPort(found[..rows]); + PatternScanner scanner = CreateScanner(port); + + Assert.True(scanner.TryScan(Request(new ModuleName("game.exe"), null, maximumResults), + out AobScanResult result, out CheatEngineFailure failure, TestContext.Current.CancellationToken), + failure.Message); + + Assert.Equal(expectedDestination, port.LastDestinationLength); + Assert.Equal(expectedTruncation, result.IsTruncated); + Assert.Equal(Math.Min(maximumResults, rows), result.Matches.Length); + } + + [Fact] + public void TheClientRangeAndModuleChecksStayAsDefensivePostFilters() + { + // CE honours the stop bound; an address outside the request is dropped and counted if it ever came back. + FakeAobScanPort port = QualifiedPort([0x4010, 0x5000, 0x4020]); + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 5), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.Equal([0x4010, 0x4020], outcome.Result!.Value.Matches); + Assert.Equal(1UL, Assert.NotNull(outcome.Metrics).FilteredOutCount); + } + + [Fact] + public void AFullDestinationOfDroppedAddressesIsIndeterminate() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.Matches) with + { + HostResultCount = 3, + Written = 2, + RowsRead = 2, + UnreadHostRows = 1, + IsMaterializationLimitReached = true + }; + PatternScanner scanner = CreateScanner(QualifiedPort([0x5000, 0x5010], bounded)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + } + + /// + /// A full destination that holds a match next to rows the Client drops leaves a host row unread: the match is + /// published as truncated, because whether the unread row is a further match is unknown. The global route reads + /// every row of the same result and proves the same copy complete. + /// + [Fact] + [Trait("Qualification", "Q28")] + public void AFullDestinationWithDroppedRowsPublishesItsMatchesAsNotProvenComplete() + { + // A 4-byte pattern in the module [0x4000, 0x4100): 0x40FC is its last whole match, 0x40FD and 0x40FF straddle + // the module end, and 0x4100 starts after it. A limit of two gives the bounded route a destination of three. + string[] hostRows = ["40FC", "40FD", "40FF", "4100"]; + AobScanRequest request = new(new AobPattern("90 90 90 90"), 2, new ModuleName("game.exe")); + + PatternScanOutcome bounded = Scan(AobHosts.Local()); + PatternScanOutcome global = Scan(AobHosts.Remote); + + Assert.True(bounded.IsSuccess, bounded.Failure?.Message); + Assert.Equal([0x40FC], bounded.Result!.Value.Matches); + Assert.True(bounded.Result.Value.IsTruncated); + PatternScanMetrics boundedMetrics = Assert.NotNull(bounded.Metrics); + Assert.Equal(PatternScanScope.HostBoundedRange, boundedMetrics.Scope); + Assert.Equal((4UL, 3UL, 2UL, 1, 1UL), + (boundedMetrics.HostResultCount, boundedMetrics.ExaminedCount, boundedMetrics.FilteredOutCount, + boundedMetrics.MaterializedCount, boundedMetrics.UnreadHostRowCount)); + Assert.False(boundedMetrics.InBoundsCountIsExact); + + Assert.True(global.IsSuccess, global.Failure?.Message); + Assert.Equal(bounded.Result.Value.Matches, global.Result!.Value.Matches); + Assert.False(global.Result.Value.IsTruncated); + PatternScanMetrics globalMetrics = Assert.NotNull(global.Metrics); + Assert.Equal(PatternScanScope.GlobalHostScanWithManagedFilter, globalMetrics.Scope); + Assert.Equal(0UL, globalMetrics.UnreadHostRowCount); + Assert.True(globalMetrics.InBoundsCountIsExact); + + PatternScanOutcome Scan(TargetSelectionFacts selection) + { + FakeAobScanPort port = new(new RecordingAobMatchList(hostRows)) + { + Modules = [Module()], + Selection = selection, + BoundedRows = [.. hostRows.Select(static row => Address.Parse(row))] + }; + return CreateScanner(port).ScanDetailed(request, TestContext.Current.CancellationToken); + } + } + + [Fact] + public void TheSdkCountsOfDroppedRowsAreReportedAsFilteredOut() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.Matches) with + { + HostResultCount = 4, + Written = 1, + RowsRead = 4, + BelowStartSkipped = 2, + AtOrAfterStopSkipped = 1 + }; + PatternScanner scanner = CreateScanner(QualifiedPort([0x4010], bounded)); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 5), + TestContext.Current.CancellationToken); + + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(4UL, metrics.HostResultCount); + Assert.Equal(4UL, metrics.ExaminedCount); + Assert.Equal(3UL, metrics.FilteredOutCount); + Assert.Equal(1, metrics.MaterializedCount); + } + + [Fact] + public void InconsistentBoundedCountsAreAnInvalidHostResult() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.Matches) with + { + HostResultCount = 1, + Written = 1, + RowsRead = 0 + }; + PatternScanner scanner = CreateScanner(QualifiedPort([0x4010], bounded)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 5), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + } + + /// An unqualified target or an SDK that cannot run the bounded route falls back to the global route. + [Theory] + [Trait("Qualification", "Q28")] + [MemberData(nameof(Fallbacks))] + public void TheRequestFallsBackToTheGlobalRouteWithManagedFilters(string reason) + { + RecordingAobMatchList matches = new(["3000", "4010", "5000"]); + AobBoundedHostResult? bounded = reason switch + { + "SessionCreationFailed" => AobHosts.Bounded(AobBoundedScanOutcomeKind.SessionCreationFailed, false) with + { + CreationStatus = MemoryScanCreationStatus.TargetIdentityUnavailable + }, + "TargetIdentityUnavailable" => AobHosts.Bounded(AobBoundedScanOutcomeKind.TargetIdentityUnavailable), + _ => null + }; + FakeAobScanPort port = new(matches) + { + Modules = [Module()], + Selection = reason switch + { + "UnqualifiedTarget" => default, + "FileAsProcess" => AobHosts.FileAsProcess, + _ => AobHosts.Local() + }, + BoundedResult = bounded + }; + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 5), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess, outcome.Failure?.Message); + Assert.Equal([0x4010], outcome.Result!.Value.Matches); + Assert.Equal(PatternScanScope.GlobalHostScanWithManagedFilter, Assert.NotNull(outcome.Metrics).Scope); + Assert.Equal(PatternScanRouteReason.TargetIdentityNotQualified, outcome.RouteReason); + Assert.False(outcome.TargetIdentityVerified); + Assert.Equal(1, port.ScanCalls); + Assert.Equal(bounded.HasValue ? 1 : 0, port.BoundedCalls); + Assert.Equal(1, matches.ReleaseCount); + } + + [Fact] + public void ASessionWhoseRollbackWasNotConfirmedIsNeverHiddenBehindAFallback() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.SessionCreationFailed, false) with + { + CreationStatus = MemoryScanCreationStatus.RollbackUnconfirmed + }; + FakeAobScanPort port = QualifiedPort([], bounded); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal(0, port.ScanCalls); + } + + [Fact] + public void AFallbackWhoseSessionReleaseWasNotConfirmedFailsInsteadOfScanningAgain() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.TargetIdentityUnavailable) with + { + FoundListRelease = TargetReleaseStatus.RefusedIdentityUnavailable + }; + FakeAobScanPort port = QualifiedPort([], bounded); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.TargetIdentityUnavailable, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal(0, port.ScanCalls); + } + + [Theory] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, TargetReleaseStatus.Released, + MemoryScanTerminationStatus.NotRequired)] + [InlineData(TargetReleaseStatus.Released, TargetReleaseStatus.RefusedTargetChanged, + MemoryScanTerminationStatus.NotRequired)] + [InlineData(TargetReleaseStatus.Released, TargetReleaseStatus.Released, MemoryScanTerminationStatus.WaitTimedOut)] + public void AnUnconfirmedSessionReleaseDiscardsTheCopy(TargetReleaseStatus foundList, TargetReleaseStatus memScan, + MemoryScanTerminationStatus termination) + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.Matches) with + { + HostResultCount = 1, + Written = 1, + RowsRead = 1, + FoundListRelease = foundList, + MemScanRelease = memScan, + ReleaseTermination = termination + }; + PatternScanner scanner = CreateScanner(QualifiedPort([0x4010], bounded)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out AobScanResult result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.StartsWith("The bounded AOB scan session release was not confirmed", failure.Message, + StringComparison.Ordinal); + } + + [Fact] + public void AFailedScanWithAnUnconfirmedSessionReleaseKeepsItsKind() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.TargetChanged) with + { + MemScanRelease = TargetReleaseStatus.RefusedTargetChanged + }; + PatternScanner scanner = CreateScanner(QualifiedPort([], bounded)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + } + + [Theory] + [InlineData(AobBoundedScanOutcomeKind.TargetChanged, CheatEngineFailureKind.TargetChanged)] + [InlineData(AobBoundedScanOutcomeKind.RuntimeInvalidated, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(AobBoundedScanOutcomeKind.ScanFailed, CheatEngineFailureKind.LuaError)] + [InlineData(AobBoundedScanOutcomeKind.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(AobBoundedScanOutcomeKind.InvalidBounds, CheatEngineFailureKind.OperationRejected)] + [InlineData(AobBoundedScanOutcomeKind.WaitTimedOut, CheatEngineFailureKind.IndeterminateHostResult)] + [InlineData(AobBoundedScanOutcomeKind.Unknown, CheatEngineFailureKind.IndeterminateHostResult)] + public void BoundedFailuresKeepTheirOwnKindsAndNeverFallBack(AobBoundedScanOutcomeKind kind, + CheatEngineFailureKind expected) + { + FakeAobScanPort port = QualifiedPort([], AobHosts.Bounded(kind)); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(expected, failure.Kind); + Assert.Equal(0, port.ScanCalls); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void CancellationBeforeTheNativeCallStartsNoScan() + { + using CancellationTokenSource cancellation = new(); + FakeAobScanPort port = QualifiedPort([0x4010], onObserveSelection: cancellation.Cancel); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, cancellation.Token)); + + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, port.BoundedCalls); + Assert.Equal(0, port.ScanCalls); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void CancellationAfterTheNativeCallPublishesNothingAndTheTokenReachesTheSdk() + { + using CancellationTokenSource cancellation = new(); + FakeAobScanPort port = QualifiedPort([0x4010], onScanWithinBounds: cancellation.Cancel); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out AobScanResult result, + out CheatEngineFailure failure, cancellation.Token)); + + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(cancellation.Token, port.LastBoundedToken); + } + + /// The SDK observes the token between its calls; the reported milestone decides the effect. + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(false, CheatEngineHostEffect.NotStarted)] + [InlineData(true, CheatEngineHostEffect.Completed)] + public void ACancellationTheSdkObservedReportsItsMilestone(bool scanCompleted, + CheatEngineHostEffect expectedEffect) + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.Cancelled, scanCompleted); + PatternScanner scanner = CreateScanner(QualifiedPort([], bounded)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + } + + [Fact] + public void AnSdkFaultFromTheBoundedCallNeverCrossesTryScan() + { + InvalidOperationException fault = new("not on the main thread"); + PatternScanner scanner = CreateScanner(QualifiedPort([], onScanWithinBounds: () => throw fault)); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Same(fault, failure.Exception); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + } + + [Fact] + public void AnSdkFaultWhileObservingTheTargetStartsNoScan() + { + LuaException fault = new("observation failed"); + FakeAobScanPort port = QualifiedPort([], onObserveSelection: () => throw fault); + PatternScanner scanner = CreateScanner(port); + + Assert.False(scanner.TryScan(Request(new ModuleName("game.exe"), null, 1), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, port.BoundedCalls); + Assert.Equal(0, port.ScanCalls); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryAobBoundedScanOutcomeKindIsClassifiedAndAnUnknownKindFailsClosed() + { + Dictionary table = new() + { + [AobBoundedScanOutcomeKind.Unknown] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.IndeterminateHostResult), + [AobBoundedScanOutcomeKind.Matches] = (AobBoundedDisposition.Publish, CheatEngineFailureKind.Unknown), + [AobBoundedScanOutcomeKind.NoMatches] = (AobBoundedDisposition.Publish, CheatEngineFailureKind.Unknown), + [AobBoundedScanOutcomeKind.InvalidBounds] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.OperationRejected), + [AobBoundedScanOutcomeKind.SessionCreationFailed] = + (AobBoundedDisposition.FallBack, CheatEngineFailureKind.CapabilityUnavailable), + [AobBoundedScanOutcomeKind.ScanFailed] = (AobBoundedDisposition.Fail, CheatEngineFailureKind.LuaError), + [AobBoundedScanOutcomeKind.WaitTimedOut] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.IndeterminateHostResult), + [AobBoundedScanOutcomeKind.HostReportedError] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.OperationRejected), + [AobBoundedScanOutcomeKind.InvalidResult] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.InvalidHostResult), + [AobBoundedScanOutcomeKind.TargetChanged] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.TargetChanged), + [AobBoundedScanOutcomeKind.TargetIdentityUnavailable] = + (AobBoundedDisposition.FallBack, CheatEngineFailureKind.TargetIdentityUnavailable), + [AobBoundedScanOutcomeKind.RuntimeInvalidated] = + (AobBoundedDisposition.Fail, CheatEngineFailureKind.RuntimeChanged), + [AobBoundedScanOutcomeKind.Cancelled] = (AobBoundedDisposition.Fail, CheatEngineFailureKind.Cancelled) + }; + + MappingTotality.AssertTotal( + kind => table.TryGetValue(kind, out (AobBoundedDisposition, CheatEngineFailureKind) expected) && + Classify(kind) == expected, + static kind => Classify(kind) == + (AobBoundedDisposition.Fail, CheatEngineFailureKind.IndeterminateHostResult)); + + static (AobBoundedDisposition, CheatEngineFailureKind) Classify(AobBoundedScanOutcomeKind kind) + { + AobBoundedDisposition disposition = AobScanMapping.ClassifyBounded("Patterns.Scan", + AobHosts.Bounded(kind), out CheatEngineFailure failure); + return (disposition, failure.Kind); + } + } + + /// + /// Only the creation statuses after which CheatEngine.SDK holds no MemScan object fall back; an unconfirmed + /// rollback, Unknown, a contradictory Success and a status a later SDK adds fail closed as + /// instead of starting a second, global scan. + /// + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMemoryScanCreationStatusIsClassifiedAndAnUnknownStatusFailsClosed() + { + MemoryScanCreationStatus[] failClosed = + [ + MemoryScanCreationStatus.Unknown, MemoryScanCreationStatus.Success, + MemoryScanCreationStatus.RollbackUnconfirmed + ]; + + MappingTotality.AssertTotal( + status => failClosed.Contains(status) + ? FailsClosed(status) + : Classify(status) is (AobBoundedDisposition.FallBack, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.NotStarted), + static status => FailsClosed(status)); + + static bool FailsClosed(MemoryScanCreationStatus status) + { + return Classify(status) is (AobBoundedDisposition.Fail, CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.CleanupUnconfirmed); + } + + static (AobBoundedDisposition, CheatEngineFailureKind, CheatEngineHostEffect) Classify( + MemoryScanCreationStatus status) + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.SessionCreationFailed, false) with + { + CreationStatus = status + }; + AobBoundedDisposition disposition = + AobScanMapping.ClassifyBounded("Patterns.Scan", bounded, out CheatEngineFailure failure); + return (disposition, failure.Kind, failure.HostEffect); + } + } + + /// + /// A cancellation observed between a fallback disposition and the global scan starts no global scan and reports the + /// bounded attempt, the only route that ran, with its own milestone. + /// + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(AobBoundedScanOutcomeKind.SessionCreationFailed, false, CheatEngineHostEffect.NotStarted, + PatternScanHostOutcomeKind.Unknown)] + [InlineData(AobBoundedScanOutcomeKind.TargetIdentityUnavailable, true, CheatEngineHostEffect.Completed, + PatternScanHostOutcomeKind.TargetIdentityUnavailable)] + public void ACancellationBeforeTheFallbackStartsNoGlobalScanAndReportsTheBoundedAttempt( + AobBoundedScanOutcomeKind kind, bool scanCompleted, CheatEngineHostEffect expectedEffect, + PatternScanHostOutcomeKind expectedHostOutcome) + { + using CancellationTokenSource cancellation = new(); + AobBoundedHostResult bounded = AobHosts.Bounded(kind, scanCompleted) with + { + CreationStatus = kind == AobBoundedScanOutcomeKind.SessionCreationFailed + ? MemoryScanCreationStatus.NoScannerResult + : MemoryScanCreationStatus.Success + }; + FakeAobScanPort port = QualifiedPort([], bounded, onScanWithinBounds: cancellation.Cancel); + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 1), + cancellation.Token); + + Assert.False(outcome.IsSuccess); + Assert.Equal(CheatEngineFailureKind.Cancelled, outcome.Failure!.Value.Kind); + Assert.Equal(expectedEffect, outcome.Failure.Value.HostEffect); + Assert.Equal(PatternScanRouteReason.ScopedRequestOnQualifiedTarget, outcome.RouteReason); + Assert.Equal(expectedHostOutcome, outcome.HostOutcome); + Assert.Equal(0, port.ScanCalls); + } + + /// A fallback after a bounded scan that had run reports the Cheat Engine time of both scans. + [Fact] + public void AFallbackAfterACompletedBoundedScanReportsTheTimeOfBothScans() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.TargetIdentityUnavailable) with + { + HostScanElapsed = TimeSpan.FromHours(1) + }; + PatternScanner scanner = CreateScanner(QualifiedPort([], bounded)); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 1), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess, outcome.Failure?.Message); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(PatternScanScope.GlobalHostScanWithManagedFilter, metrics.Scope); + Assert.True(metrics.HostScanElapsed >= TimeSpan.FromHours(1)); + } + + /// + /// A cancellation observed after CheatEngine.SDK read the host count keeps the metrics of that work, whether the + /// SDK observed it between rows or the Client observed it before publishing. + /// + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(true)] + [InlineData(false)] + public void ABoundedCancellationAfterTheCountWasReadKeepsItsMetrics(bool observedBySdk) + { + using CancellationTokenSource cancellation = new(); + AobBoundedHostResult? sdkCancelled = observedBySdk + ? AobHosts.Bounded(AobBoundedScanOutcomeKind.Cancelled) with + { + HostResultCount = 3, + RowsRead = 1, + UnreadHostRows = 2, + CopyElapsed = TimeSpan.FromMilliseconds(1) + } + : null; + FakeAobScanPort port = QualifiedPort([0x4010, 0x4020, 0x4030], sdkCancelled, + onScanWithinBounds: observedBySdk ? null : cancellation.Cancel); + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), null, 5), + observedBySdk ? TestContext.Current.CancellationToken : cancellation.Token); + + Assert.False(outcome.IsSuccess); + Assert.Equal(CheatEngineFailureKind.Cancelled, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, outcome.Failure.Value.HostEffect); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(PatternScanScope.HostBoundedRange, metrics.Scope); + Assert.Equal(3UL, metrics.HostResultCount); + Assert.Equal(observedBySdk ? 1UL : 3UL, metrics.ExaminedCount); + Assert.Equal(0, metrics.MaterializedCount); + Assert.False(metrics.InBoundsCountIsExact); + } + + [Fact] + public void TheSessionReleaseIsConfirmedOnlyWhenBothOwnersAreReleasedAndNoStopIsPending() + { + AobBoundedHostResult released = AobHosts.Bounded(AobBoundedScanOutcomeKind.Matches); + AobBoundedHostResult confirmedStop = released with + { + ReleaseTermination = MemoryScanTerminationStatus.Confirmed + }; + AobBoundedHostResult noSession = AobHosts.Bounded(AobBoundedScanOutcomeKind.InvalidBounds) with + { + CreationStatus = MemoryScanCreationStatus.Unknown, + FoundListRelease = TargetReleaseStatus.Unspecified, + MemScanRelease = TargetReleaseStatus.Unspecified + }; + AobBoundedHostResult pendingStop = released with + { + ReleaseTermination = MemoryScanTerminationStatus.NotInvoked + }; + + Assert.True(AobScanMapping.IsSessionReleaseConfirmed(released, out _)); + Assert.True(AobScanMapping.IsSessionReleaseConfirmed(confirmedStop, out _)); + Assert.True(AobScanMapping.IsSessionReleaseConfirmed(noSession, out _)); + Assert.False(AobScanMapping.IsSessionReleaseConfirmed(pendingStop, out LeaseReleaseKind pendingKind)); + Assert.Equal(LeaseReleaseKind.CleanupUnconfirmed, pendingKind); + } + + private static FakeAobScanPort QualifiedPort(Address[] rows, AobBoundedHostResult? result = null, + Action? onObserveSelection = null, Action? onScanWithinBounds = null) + { + return new FakeAobScanPort(new RecordingAobMatchList(["4010"])) + { + Modules = [Module()], + Selection = AobHosts.Local(), + BoundedRows = rows, + BoundedResult = result, + OnObserveSelection = onObserveSelection, + OnScanWithinBounds = onScanWithinBounds + }; + } + + private static ModuleInfo Module() + { + return new ModuleInfo("game.exe", new Address(ModuleBase), new MemorySize(ModuleSize), true, "game.exe"); + } + + private static PatternScanner CreateScanner(FakeAobScanPort port) + { + return new PatternScanner( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + } + + private static AobScanRequest Request(ModuleName? module, AobScanRange? range, int maximumResults) + { + return new AobScanRequest(new AobPattern("90 90"), maximumResults, module, range); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs index 624ad8f..576a854 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs @@ -1,38 +1,32 @@ using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Tests.TestSupport; -using CheatEngine.Client.Results; -using CheatEngine.Client.Scanning; namespace CheatEngine.Client.Core.Tests.Domains; public sealed class PatternScannerCoverageTests { [Fact] - public void TryScanRejectsAnUninitializedRequestBeforeAccessingThePluginDispatcher() + public void TryScanThrowsForAnUninitializedRequestBeforeAccessingThePluginDispatcher() { PatternScanner scanner = CreateScanner(); - bool succeeded = scanner.TryScan(default, out AobScanResult result, out CheatEngineFailure failure, - TestContext.Current.CancellationToken); + ArgumentException exception = Assert.Throws(() => scanner.TryScan(default, out _, out _, + TestContext.Current.CancellationToken)); - Assert.False(succeeded); - Assert.Equal(default, result); - Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); - Assert.Equal("Patterns.Scan", failure.Operation); - Assert.Contains("normalized, non-empty pattern", failure.Message, StringComparison.Ordinal); + Assert.Equal("request", exception.ParamName); + Assert.Contains("normalized, non-empty pattern", exception.Message, StringComparison.Ordinal); } [Fact] - public void ScanConvertsAnUninitializedRequestFailureToThePublicOperationException() + public void ScanThrowsTheArgumentExceptionOfAnUninitializedRequest() { PatternScanner scanner = CreateScanner(); - CheatEngineOperationException exception = Assert.Throws(() => + ArgumentException exception = Assert.Throws(() => scanner.Scan(default, TestContext.Current.CancellationToken)); - Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); - Assert.Equal("Patterns.Scan", exception.Failure.Operation); + Assert.Equal("request", exception.ParamName); Assert.Contains("normalized, non-empty pattern", exception.Message, StringComparison.Ordinal); } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerDetailedOutcomeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerDetailedOutcomeTests.cs new file mode 100644 index 0000000..6913bf2 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerDetailedOutcomeTests.cs @@ -0,0 +1,269 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// reports, per route, the host's own outcome, why the scan ran on its route, +/// whether the target identity was verified, and the widened metrics (audit F06, F07, API-03). +/// +public sealed class PatternScannerDetailedOutcomeTests +{ + [Fact] + [Trait("Qualification", "Q27")] + public void AnUnscopedScanReportsTheGlobalRouteAndAVerifiedTarget() + { + PatternScanner scanner = CreateScanner(new FakeAobScanPort(new RecordingAobMatchList(["400000", "400010"]))); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(null, 5), TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.Equal(PatternScanHostOutcomeKind.Matches, outcome.HostOutcome); + Assert.Equal(PatternScanRouteReason.UnscopedRequest, outcome.RouteReason); + Assert.True(outcome.TargetIdentityVerified); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(PatternScanScope.GlobalHostScan, metrics.Scope); + Assert.Equal(2UL, metrics.HostResultCount); + Assert.Equal(0UL, metrics.UnreadHostRowCount); + Assert.True(metrics.InBoundsCountIsExact); + Assert.Equal(0UL, metrics.BelowStartSkippedCount); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void ANilGlobalResultReportsItsHostOutcomeWithoutMetrics() + { + PatternScanner scanner = CreateScanner(new FakeAobScanPort + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.NoResult) + }); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(null, 1), TestContext.Current.CancellationToken); + + Assert.False(outcome.IsSuccess); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, outcome.Failure!.Value.Kind); + Assert.Equal(PatternScanHostOutcomeKind.NoResult, outcome.HostOutcome); + Assert.Equal(PatternScanRouteReason.UnscopedRequest, outcome.RouteReason); + Assert.False(outcome.TargetIdentityVerified); + Assert.Null(outcome.Metrics); + } + + [Fact] + public void AGlobalScanOnAnUnqualifiedTargetIsNotVerified() + { + RecordingAobMatchList matches = new(["400000"]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches) + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.Matches, AobHosts.FileAsProcess, AobHosts.FileAsProcess, 1) + }); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(null, 1), TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.False(outcome.TargetIdentityVerified); + } + + [Fact] + public void AGlobalTargetChangeKeepsTheHostOutcomeAndReportsTheClientVerdict() + { + PatternScanner scanner = CreateScanner(new FakeAobScanPort(new RecordingAobMatchList(["400000"])) + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.Matches, AobHosts.Local(), AobHosts.Local(43, 2_000), 1) + }); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(null, 1), TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineFailureKind.TargetChanged, outcome.Failure!.Value.Kind); + Assert.Equal(PatternScanHostOutcomeKind.Matches, outcome.HostOutcome); + Assert.False(outcome.TargetIdentityVerified); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void ABoundedScanReportsItsRouteItsSkipsAndAVerifiedTarget() + { + AobBoundedHostResult bounded = AobHosts.Bounded(AobBoundedScanOutcomeKind.Matches) with + { + HostResultCount = 5, + Written = 2, + RowsRead = 5, + BelowStartSkipped = 2, + AtOrAfterStopSkipped = 1, + CopyElapsed = TimeSpan.FromMilliseconds(1) + }; + PatternScanner scanner = CreateScanner(QualifiedPort([0x4010, 0x4020], bounded)); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), 5), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess); + Assert.Equal(PatternScanHostOutcomeKind.Matches, outcome.HostOutcome); + Assert.Equal(PatternScanRouteReason.ScopedRequestOnQualifiedTarget, outcome.RouteReason); + Assert.True(outcome.TargetIdentityVerified); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(PatternScanScope.HostBoundedRange, metrics.Scope); + Assert.Equal(5UL, metrics.HostResultCount); + Assert.Equal(5UL, metrics.ExaminedCount); + Assert.Equal(3UL, metrics.FilteredOutCount); + Assert.Equal(2UL, metrics.BelowStartSkippedCount); + Assert.Equal(1UL, metrics.AtOrAfterStopSkippedCount); + Assert.Equal(0UL, metrics.UnreadHostRowCount); + Assert.True(metrics.InBoundsCountIsExact); + Assert.True(metrics.HostScanElapsed > TimeSpan.Zero); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void ATruncatedBoundedScanWithUnreadRowsIsNotAnExactCount() + { + PatternScanner scanner = CreateScanner(QualifiedPort([0x4010, 0x4020, 0x4030])); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), 1), + TestContext.Current.CancellationToken); + + Assert.True(outcome.Result!.Value.IsTruncated); + PatternScanMetrics metrics = Assert.NotNull(outcome.Metrics); + Assert.Equal(1UL, metrics.UnreadHostRowCount); + Assert.False(metrics.InBoundsCountIsExact); + } + + [Fact] + public void ABoundedFailureReportsItsHostOutcomeAndNoVerifiedTarget() + { + PatternScanner scanner = CreateScanner(QualifiedPort([], + AobHosts.Bounded(AobBoundedScanOutcomeKind.HostReportedError) with + { + HostErrorText = "error" + })); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), 1), + TestContext.Current.CancellationToken); + + Assert.False(outcome.IsSuccess); + Assert.Equal(PatternScanHostOutcomeKind.HostReportedError, outcome.HostOutcome); + Assert.Equal(PatternScanRouteReason.ScopedRequestOnQualifiedTarget, outcome.RouteReason); + Assert.False(outcome.TargetIdentityVerified); + Assert.False(Assert.NotNull(outcome.Metrics).InBoundsCountIsExact); + } + + /// + /// A fallback never reports a verified target, even after a session-creation failure on a qualified target whose + /// global scan saw the same incarnation before and after: the reason says the identity was not qualified, and the + /// flag agrees with it (plan L11, commit 2). + /// + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(false)] + [InlineData(true)] + public void AFallbackReportsTheGlobalHostOutcomeAndTheUnqualifiedRoute(bool afterSessionFailure) + { + AobBoundedHostResult? bounded = afterSessionFailure + ? AobHosts.Bounded(AobBoundedScanOutcomeKind.SessionCreationFailed, false) with + { + CreationStatus = MemoryScanCreationStatus.NoScannerResult + } + : null; + FakeAobScanPort port = new(new RecordingAobMatchList(["4010"])) + { + Modules = [Module()], + Selection = afterSessionFailure ? AobHosts.Local() : AobHosts.Remote, + BoundedResult = bounded + }; + PatternScanner scanner = CreateScanner(port); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("game.exe"), 1), + TestContext.Current.CancellationToken); + + Assert.True(outcome.IsSuccess, outcome.Failure?.Message); + Assert.Equal(PatternScanScope.GlobalHostScanWithManagedFilter, Assert.NotNull(outcome.Metrics).Scope); + Assert.Equal(PatternScanRouteReason.TargetIdentityNotQualified, outcome.RouteReason); + Assert.Equal(PatternScanHostOutcomeKind.Matches, outcome.HostOutcome); + Assert.False(outcome.TargetIdentityVerified); + } + + [Fact] + public void ARefusalBeforeAnyRouteReportsNoRouteAndNoHostOutcome() + { + PatternScanner scanner = CreateScanner(new FakeAobScanPort + { + Modules = [Module()] + }); + + PatternScanOutcome outcome = scanner.ScanDetailed(Request(new ModuleName("other.exe"), 1), + TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineFailureKind.NotFound, outcome.Failure!.Value.Kind); + Assert.Equal(PatternScanRouteReason.Unknown, outcome.RouteReason); + Assert.Equal(PatternScanHostOutcomeKind.Unknown, outcome.HostOutcome); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryGlobalOutcomeKindHasItsHostOutcomeAndAnUnknownKindFailsClosed() + { + MappingTotality.AssertTotal( + static kind => AobScanMapping.ToHostOutcome(kind).ToString() == kind.ToString(), + static kind => AobScanMapping.ToHostOutcome(kind) == PatternScanHostOutcomeKind.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryBoundedOutcomeKindHasItsHostOutcomeAndAnUnknownKindFailsClosed() + { + AobBoundedScanOutcomeKind[] withoutHostOutcome = + [ + AobBoundedScanOutcomeKind.InvalidBounds, AobBoundedScanOutcomeKind.SessionCreationFailed, + AobBoundedScanOutcomeKind.WaitTimedOut + ]; + + MappingTotality.AssertTotal( + kind => withoutHostOutcome.Contains(kind) + ? AobScanMapping.ToHostOutcome(kind) == PatternScanHostOutcomeKind.Unknown + : kind switch + { + // The Client names a replaced Lua runtime RuntimeChanged, like the failure kind. + AobBoundedScanOutcomeKind.RuntimeInvalidated => + AobScanMapping.ToHostOutcome(kind) == PatternScanHostOutcomeKind.RuntimeChanged, + // A protected call that raised has one member on both routes. + AobBoundedScanOutcomeKind.ScanFailed => + AobScanMapping.ToHostOutcome(kind) == PatternScanHostOutcomeKind.ProtectedLuaFailure, + _ => AobScanMapping.ToHostOutcome(kind).ToString() == kind.ToString() + }, + static kind => AobScanMapping.ToHostOutcome(kind) == PatternScanHostOutcomeKind.Unknown); + } + + private static FakeAobScanPort QualifiedPort(Address[] rows, AobBoundedHostResult? result = null) + { + return new FakeAobScanPort(new RecordingAobMatchList(["4010"])) + { + Modules = [Module()], + Selection = AobHosts.Local(), + BoundedRows = rows, + BoundedResult = result + }; + } + + private static ModuleInfo Module() + { + return new ModuleInfo("game.exe", new Address(0x4000), new MemorySize(0x100), true, "game.exe"); + } + + private static PatternScanner CreateScanner(FakeAobScanPort port) + { + return new PatternScanner( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + } + + private static AobScanRequest Request(ModuleName? module, int maximumResults) + { + return new AobScanRequest(new AobPattern("90 90"), maximumResults, module); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerHostOutcomeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerHostOutcomeTests.cs new file mode 100644 index 0000000..c8e8bff --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerHostOutcomeTests.cs @@ -0,0 +1,384 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The global route classifies every AobScanner.TryScanOutcome outcome and its target context (audit F06, +/// CRIT-03), and releases the result list through the SDK's release outcome (F13). +/// +public sealed class PatternScannerHostOutcomeTests +{ + private const string ScanOperation = "Patterns.Scan"; + + private static readonly Dictionary + WithoutListTable = new() + { + [AobScanOutcomeKind.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + [AobScanOutcomeKind.Matches] = (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + [AobScanOutcomeKind.NoMatches] = (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + [AobScanOutcomeKind.GlobalUnavailable] = + (CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted), + [AobScanOutcomeKind.ProtectedLuaFailure] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Unknown), + [AobScanOutcomeKind.NoResult] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Completed), + [AobScanOutcomeKind.InvalidResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + [AobScanOutcomeKind.ResultListCountUnavailable] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed) + }; + + public static TheoryData F06Cases => + [ + "AbsentGlobal", + "NilResult", + "ProtectedError", + "MalformedResult", + "EmptyList" + ]; + + /// The five F06 acceptance cases: each SDK outcome keeps its own meaning, and only a list is a success. + [Theory] + [Trait("Qualification", "Q27")] + [MemberData(nameof(F06Cases))] + public void EachGlobalHostOutcomeKeepsItsOwnMeaning(string outcome) + { + (FakeAobScanPort port, bool expectedSuccess, CheatEngineFailureKind expectedKind, + CheatEngineHostEffect expectedEffect) = outcome switch + { + "AbsentGlobal" => (Port(AobScanOutcomeKind.GlobalUnavailable), false, + CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted), + "NilResult" => (Port(AobScanOutcomeKind.NoResult), false, + CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Completed), + "ProtectedError" => (Port(AobScanOutcomeKind.ProtectedLuaFailure), false, CheatEngineFailureKind.LuaError, + CheatEngineHostEffect.Unknown), + "MalformedResult" => (Port(AobScanOutcomeKind.InvalidResult), false, + CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + "EmptyList" => (new FakeAobScanPort(new RecordingAobMatchList([])), true, CheatEngineFailureKind.Unknown, + CheatEngineHostEffect.Unknown), + _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null) + }; + PatternScanner scanner = CreateScanner(port); + + bool succeeded = scanner.TryScan(Request(), out AobScanResult result, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.Equal(expectedSuccess, succeeded); + Assert.Equal(1, port.ScanCalls); + Assert.NotEqual(CheatEngineFailureKind.NotFound, failure.Kind); + if (expectedSuccess) + { + Assert.Equal(default, failure); + Assert.Empty(result.Matches); + Assert.False(result.IsTruncated); + return; + } + + Assert.Equal(default, result); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.Equal(ScanOperation, failure.Operation); + Assert.Null(failure.Exception); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void ANilResultNamesItsSharedShapeAndTheProtectedErrorNamesItsLuaStatus() + { + PatternScanner nil = CreateScanner(Port(AobScanOutcomeKind.NoResult)); + PatternScanner raising = CreateScanner(Port(AobScanOutcomeKind.ProtectedLuaFailure)); + + Assert.False(nil.TryScan(Request(), out _, out CheatEngineFailure nilFailure, + TestContext.Current.CancellationToken)); + Assert.False(raising.TryScan(Request(), out _, out CheatEngineFailure raisingFailure, + TestContext.Current.CancellationToken)); + + Assert.Equal("CE AOBScan returned nil: on CE 7.7 zero matches and host failures share this shape", + nilFailure.Message); + Assert.Contains(LuaStatus.RuntimeError.ToString(), raisingFailure.Message, StringComparison.Ordinal); + } + + /// A target change during the scan discards the addresses, which may belong to another process. + [Theory] + [Trait("Qualification", "Q27")] + [Trait("Qualification", "Q30.a")] + [InlineData("OtherProcess", CheatEngineFailureKind.TargetChanged)] + [InlineData("ReusedProcessId", CheatEngineFailureKind.TargetChanged)] + [InlineData("IdentityLost", CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData("IdentityGained", CheatEngineFailureKind.TargetIdentityUnavailable)] + public void ATargetChangeDuringTheScanDiscardsItsAddresses(string change, CheatEngineFailureKind expectedKind) + { + (TargetSelectionFacts before, TargetSelectionFacts after) = change switch + { + "OtherProcess" => (AobHosts.Local(), AobHosts.Local(43, 2_000)), + "ReusedProcessId" => (AobHosts.Local(), AobHosts.Local(42, 9_000)), + "IdentityLost" => (AobHosts.Local(), AobHosts.Remote), + "IdentityGained" => (AobHosts.Remote, AobHosts.Local()), + _ => throw new ArgumentOutOfRangeException(nameof(change), change, null) + }; + RecordingAobMatchList matches = new(["400000", "400010"]); + FakeAobScanPort port = new(matches) + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.Matches, before, after, 2) + }; + PatternScanner scanner = CreateScanner(port); + + bool succeeded = scanner.TryScan(Request(), out AobScanResult result, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(0, matches.ItemCalls); + Assert.Equal(1, matches.ReleaseCount); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void ANilResultOnAChangedTargetIsATargetChangeNotAnIndeterminateZero() + { + PatternScanner scanner = CreateScanner(new FakeAobScanPort + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.NoResult, AobHosts.Local(), AobHosts.Local(43, 2_000)) + }); + + Assert.False(scanner.TryScan(Request(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + } + + /// An unqualified target that did not visibly change keeps its answer (the scan is unverified, not refused). + [Theory] + [InlineData("FileAsProcess")] + [InlineData("Remote")] + public void AnUnqualifiedTargetThatDidNotChangeKeepsItsAnswer(string target) + { + TargetSelectionFacts selection = target == "Remote" ? AobHosts.Remote : AobHosts.FileAsProcess; + RecordingAobMatchList matches = new(["400000"]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches) + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.Matches, selection, selection, 1) + }); + + bool succeeded = scanner.TryScan(Request(), out AobScanResult result, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded, failure.Message); + Assert.Equal([0x400000], result.Matches); + Assert.Equal(1, matches.ReleaseCount); + } + + [Fact] + public void ASuccessfulOutcomeWithoutAListIsAnInvalidHostResult() + { + PatternScanner scanner = CreateScanner(new FakeAobScanPort + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.Matches, 3) + }); + + Assert.False(scanner.TryScan(Request(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + } + + [Fact] + public void AListHandedOutWithAFailedOutcomeIsReleasedOnceAndNeverCopied() + { + RecordingAobMatchList matches = new(["400000"]); + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches) + { + Outcome = AobHosts.Outcome(AobScanOutcomeKind.NoResult) + }); + + Assert.False(scanner.TryScan(Request(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(0, matches.ItemCalls); + Assert.Equal(1, matches.ReleaseCount); + } + + [Fact] + public void ACopyFaultIsClassifiedThroughTheSdkBoundaryAndStillReleasesTheList() + { + LuaException fault = new("the list could not be read"); + RecordingAobMatchList matches = new(["400000"]) + { + OnTryGetItem = _ => throw fault + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + + bool succeeded = scanner.TryScan(Request(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Same(fault, failure.Exception); + Assert.Equal(1, matches.ReleaseCount); + } + + /// + /// The copy-fault path goes through SdkBoundary, including its activation check: a fault observed after the + /// activation ended is an expired activation, never an ordinary failure, and the list is still released once. + /// + [Fact] + public void ACopyFaultAfterTheActivationEndedIsAnExpiredActivationAndStillReleasesTheList() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + LuaException fault = new("the list could not be read"); + RecordingAobMatchList matches = new(["400000"]) + { + OnTryGetItem = _ => + { + context.IsCurrent = false; + throw fault; + } + }; + PatternScanner scanner = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), + new FakeAobScanPort(matches)); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + scanner.TryScan(Request(), out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(ScanOperation, exception.Failure.Operation); + Assert.Same(fault, exception.Failure.Exception); + Assert.Equal(1, matches.ReleaseCount); + } + + /// + /// A list the port could not publish, whose release was not confirmed, is reported by its typed release kind as + /// , keeping the publication fault's classification. + /// + [Theory] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, "CleanupUnconfirmed")] + [InlineData(TargetReleaseStatus.NotInvoked, "CleanupUnavailable")] + public void AListWhosePublicationFailedAndWhoseReleaseWasNotConfirmedIsCleanupUnconfirmed( + TargetReleaseStatus releaseStatus, string expectedKind) + { + InvalidOperationException publishFailure = new("the result list wrapper could not be published"); + PatternScanner scanner = CreateScanner(new FakeAobScanPort + { + OnScan = () => _ = OwnershipHandoff.Adopt(new object(), _ => throw publishFailure, + _ => SdkReleaseOutcomes.FromTarget(releaseStatus)) + }); + + Assert.False(scanner.TryScan(Request(), out AobScanResult result, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal(ScanOperation, failure.Operation); + Assert.Same(publishFailure, failure.Exception); + Assert.EndsWith($"The AOB result list release was not confirmed ({expectedKind}).", failure.Message, + StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryAobScanOutcomeKindWithoutAListIsMappedAndAnUnknownKindFailsClosed() + { + MappingTotality.AssertTotal( + static kind => WithoutListTable.TryGetValue(kind, out (CheatEngineFailureKind Kind, + CheatEngineHostEffect Effect) expected) && + Matches(AobScanMapping.ToFailure(ScanOperation, AobHosts.Outcome(kind)), expected), + static kind => Matches(AobScanMapping.ToFailure(ScanOperation, AobHosts.Outcome(kind)), + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown))); + } + + /// Only a confirmed release lets a copied result through; every other status is CleanupUnconfirmed. + [Fact] + [Trait("Qualification", "Q48")] + public void EveryTargetReleaseStatusButReleasedDiscardsTheCopyAsCleanupUnconfirmed() + { + MappingTotality.AssertTotal( + static status => ScanWithRelease(status) is var (succeeded, failure) && + (status == TargetReleaseStatus.Released + ? succeeded + : !succeeded && failure.HostEffect == CheatEngineHostEffect.CleanupUnconfirmed && + failure.Kind == CheatEngineFailureKind.IndeterminateHostResult), + static status => ScanWithRelease(status) is (false, { HostEffect: CheatEngineHostEffect.CleanupUnconfirmed })); + } + + [Theory] + [InlineData("SameIncarnation", nameof(AobTargetVerdict.Verified))] + [InlineData("OtherIncarnation", nameof(AobTargetVerdict.Changed))] + [InlineData("OtherUnqualifiedProcess", nameof(AobTargetVerdict.Changed))] + [InlineData("QualifiedOnOneSide", nameof(AobTargetVerdict.IdentityUnavailable))] + [InlineData("UnqualifiedStatusChanged", nameof(AobTargetVerdict.IdentityUnavailable))] + [InlineData("SameUnqualifiedSelection", nameof(AobTargetVerdict.Unverified))] + [InlineData("NothingObserved", nameof(AobTargetVerdict.Unverified))] + public void TheTargetVerdictFollowsTheTwoObservations(string pair, string expected) + { + (TargetSelectionFacts before, TargetSelectionFacts after) = pair switch + { + "SameIncarnation" => (AobHosts.Local(), AobHosts.Local()), + "OtherIncarnation" => (AobHosts.Local(), AobHosts.Local(42, 5_000)), + "OtherUnqualifiedProcess" => (AobHosts.Remote, AobHosts.Remote with { SelectedProcessId = 7 }), + "QualifiedOnOneSide" => (AobHosts.Local(), AobHosts.Remote), + "UnqualifiedStatusChanged" => (AobHosts.Remote, AobHosts.Remote with + { + Status = TargetSelectionObservationStatus.CurrentTargetUnqualified + }), + "SameUnqualifiedSelection" => (AobHosts.FileAsProcess, AobHosts.FileAsProcess), + "NothingObserved" => (default(TargetSelectionFacts), default(TargetSelectionFacts)), + _ => throw new ArgumentOutOfRangeException(nameof(pair), pair, null) + }; + + Assert.Equal(Enum.Parse(expected), AobScanMapping.JudgeTarget(before, after)); + } + + private static (bool Succeeded, CheatEngineFailure Failure) ScanWithRelease(TargetReleaseStatus status) + { + RecordingAobMatchList matches = new(["400000"]) + { + ReleaseStatus = status + }; + PatternScanner scanner = CreateScanner(new FakeAobScanPort(matches)); + bool succeeded = scanner.TryScan(Request(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + Assert.Equal(1, matches.ReleaseCount); + return (succeeded, failure); + } + + private static bool Matches(CheatEngineFailure failure, + (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) expected) + { + return failure.Kind == expected.Kind && failure.HostEffect == expected.Effect && + failure.Operation == ScanOperation && !string.IsNullOrWhiteSpace(failure.Message); + } + + private static FakeAobScanPort Port(AobScanOutcomeKind kind) + { + return new FakeAobScanPort + { + Outcome = AobHosts.Outcome(kind) + }; + } + + private static PatternScanner CreateScanner(FakeAobScanPort port) + { + return new PatternScanner( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + } + + private static AobScanRequest Request() + { + return new AobScanRequest(new AobPattern("90"), 2); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs index adea745..65f5f88 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs @@ -1,30 +1,182 @@ +using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Tests.Domains; public sealed class PatternScannerTests { + private const ScanProtectionRequirement Unspecified = ScanProtectionRequirement.Unspecified; + private const ScanProtectionRequirement Required = ScanProtectionRequirement.Required; + private const ScanProtectionRequirement Excluded = ScanProtectionRequirement.Excluded; + private const ScanProtectionRequirement Any = ScanProtectionRequirement.Any; + [Fact] - public void TryValidateRequestRejectsTheDefaultStructBeforeAnyDispatcherOrSdkOperation() + public void ValidateRequestThrowsForTheDefaultStructBeforeAnyDispatcherOrSdkOperation() + { + ArgumentException thrown = Assert.Throws(() => PatternScanner.ValidateRequest(default)); + + Assert.Equal("request", thrown.ParamName); + } + + [Fact] + public void ValidateRequestAcceptsAConstructedBoundedRequest() + { + AobScanRequest request = new(new AobPattern("90"), 1); + + Exception? thrown = Record.Exception(() => PatternScanner.ValidateRequest(request)); + + Assert.Null(thrown); + } + + /// + /// The Client-owned options reach Cheat Engine as its own protection text and fast-scan method; the default filter + /// is the empty text, the SDK-documented "find everything" value, never an omitted argument. + /// + [Theory] + [InlineData(Unspecified, Unspecified, Unspecified, "")] + [InlineData(Required, Excluded, Excluded, "+X-C-W")] + [InlineData(Unspecified, Excluded, Required, "-C+W")] + [InlineData(Any, Any, Any, "*X*C*W")] + [InlineData(Excluded, Unspecified, Unspecified, "-X")] + public void TheProtectionFilterBecomesCheatEngineProtectionText(ScanProtectionRequirement executable, + ScanProtectionRequirement copyOnWrite, ScanProtectionRequirement writable, string expected) { - bool valid = PatternScanner.TryValidateRequest(default, out CheatEngineFailure failure); + AobScanOptions options = AobScanMapping.ToSdkOptions( + new ScanProtectionFilter(executable, copyOnWrite, writable), ScanAlignment.None); - Assert.False(valid); - Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); - Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(expected, options.ProtectionFlags); + Assert.Equal(FastScanMethod.NotAligned, options.AlignmentMethod); + Assert.Null(options.AlignmentParameter); } [Fact] - public void TryValidateRequestAcceptsAConstructedBoundedRequest() + public void TheAlignmentRuleBecomesCheatEngineFastScanMethodAndParameter() + { + AobScanOptions aligned = AobScanMapping.ToSdkOptions(default, ScanAlignment.AlignedTo(16)); + AobScanOptions lastDigits = AobScanMapping.ToSdkOptions(default, ScanAlignment.LastDigits("f0")); + + Assert.Equal(FastScanMethod.Aligned, aligned.AlignmentMethod); + Assert.Equal("16", aligned.AlignmentParameter); + Assert.Equal(FastScanMethod.LastDigits, lastDigits.AlignmentMethod); + Assert.Equal("F0", lastDigits.AlignmentParameter); + Assert.Equal(string.Empty, lastDigits.ProtectionFlags); + } + + /// Both routes send the same explicit protection text for the default filter. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TheDefaultFilterIsTheSameEmptyProtectionTextOnBothRoutes(bool bounded) + { + FakeAobScanPort port = new(new RecordingAobMatchList(["4010"])) + { + Modules = [new ModuleInfo("game.exe", 0x4000, new MemorySize(0x100), true, "game.exe")], + Selection = bounded ? AobHosts.Local() : default, + BoundedRows = [0x4010] + }; + PatternScanner scanner = new( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + + Assert.True(scanner.TryScan(new AobScanRequest(new AobPattern("90"), 1, new ModuleName("game.exe")), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken), failure.Message); + + AobScanOptions options = Assert.NotNull(port.LastOptions); + Assert.Equal(string.Empty, options.ProtectionFlags); + Assert.Equal(FastScanMethod.NotAligned, options.AlignmentMethod); + Assert.Equal(bounded ? 1 : 0, port.BoundedCalls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TheOptionsReachBothRoutes(bool bounded) + { + FakeAobScanPort port = new(new RecordingAobMatchList(["4010"])) + { + Modules = [new ModuleInfo("game.exe", 0x4000, new MemorySize(0x100), true, "game.exe")], + Selection = bounded ? AobHosts.Local() : default, + BoundedRows = [0x4010] + }; + PatternScanner scanner = new( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + AobScanRequest request = new(new AobPattern("90"), 1, new ModuleName("game.exe"), null, + new ScanProtectionFilter(Required, Excluded, Excluded), ScanAlignment.AlignedTo(4)); + + Assert.True(scanner.TryScan(request, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken), failure.Message); + + AobScanOptions options = Assert.NotNull(port.LastOptions); + Assert.Equal("+X-C-W", options.ProtectionFlags); + Assert.Equal(FastScanMethod.Aligned, options.AlignmentMethod); + Assert.Equal("4", options.AlignmentParameter); + Assert.Equal(bounded ? 1 : 0, port.BoundedCalls); + } + + /// + /// The default request, and a request whose field or option was tampered with, are programming errors: every + /// entry point throws what the request's constructor throws for the same value, before any Cheat Engine call. + /// + [Theory] + [InlineData("Default", typeof(ArgumentException))] + [InlineData("NegativeLimit", typeof(ArgumentOutOfRangeException))] + [InlineData("EmptyModule", typeof(ArgumentException))] + [InlineData("InvertedRange", typeof(ArgumentOutOfRangeException))] + [InlineData("Protection", typeof(ArgumentOutOfRangeException))] + [InlineData("AlignmentKind", typeof(ArgumentOutOfRangeException))] + [InlineData("AlignmentDivisor", typeof(ArgumentOutOfRangeException))] + public void AnInvalidRequestThrowsFromEveryEntryPointBeforeAnyCheatEngineCall(string invalid, Type expected) { - AobScanRequest request = new(new AobPattern("90"), AobScanOptions.Default, 1); + FakeAobScanPort port = new(new RecordingAobMatchList(["4010"])); + PatternScanner scanner = new( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), port); + AobScanRequest request = CreateInvalidRequest(invalid); + CancellationToken token = TestContext.Current.CancellationToken; - bool valid = PatternScanner.TryValidateRequest(request, out CheatEngineFailure failure); + Exception[] thrown = + [ + Assert.ThrowsAny(() => scanner.TryScan(request, out _, out _, token)), + Assert.ThrowsAny(() => scanner.Scan(request, token)), + Assert.ThrowsAny(() => scanner.ScanDetailed(request, token)) + ]; - Assert.True(valid); - Assert.Equal(default, failure); + Assert.All(thrown, exception => + { + Assert.IsType(expected, exception); + Assert.Equal("request", ((ArgumentException) exception).ParamName); + }); + Assert.Equal(0, port.EnumerationCalls); + Assert.Equal(0, port.SelectionCalls); + Assert.Equal(0, port.ScanCalls); + } + + private static AobScanRequest CreateInvalidRequest(string invalid) + { + AobScanRequest valid = new(new AobPattern("90"), 1); + return invalid switch + { + "Default" => default, + "NegativeLimit" => TamperedValues.WithBackingField(valid, nameof(AobScanRequest.MaximumResults), -1), + "EmptyModule" => TamperedValues.WithBackingField(valid, nameof(AobScanRequest.Module), + (ModuleName?) default(ModuleName)), + "InvertedRange" => TamperedValues.WithBackingField(valid, nameof(AobScanRequest.Range), + (AobScanRange?) TamperedValues.WithBackingField(new AobScanRange(new Address(0x10), new Address(0x10)), + nameof(AobScanRange.Start), new Address(0x20))), + "Protection" => TamperedValues.WithBackingField(valid, nameof(AobScanRequest.Protection), + TamperedValues.WithBackingField(default(ScanProtectionFilter), nameof(ScanProtectionFilter.Writable), + (ScanProtectionRequirement) 9)), + "AlignmentKind" => TamperedValues.WithBackingField(valid, nameof(AobScanRequest.Alignment), + TamperedValues.WithBackingField(ScanAlignment.None, nameof(ScanAlignment.Mode), (ScanAlignmentMode) 9)), + "AlignmentDivisor" => TamperedValues.WithBackingField(valid, nameof(AobScanRequest.Alignment), + TamperedValues.WithBackingField(ScanAlignment.AlignedTo(4), nameof(ScanAlignment.Mode), + ScanAlignmentMode.None)), + _ => throw new ArgumentOutOfRangeException(nameof(invalid), invalid, null) + }; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientLocalProcessesTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientLocalProcessesTests.cs new file mode 100644 index 0000000..8be8d5c --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientLocalProcessesTests.cs @@ -0,0 +1,189 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// is an offline diagnostic of the local operating-system catalog: +/// it never reaches Cheat Engine, needs no current activation, and reports a cancellation as NotStarted. +/// +public sealed class ProcessClientLocalProcessesTests +{ + [Fact] + public void GetLocalProcessesReturnsBoundedOrderedCopiedLocalMetadata() + { + (ProcessClient client, FakeLocalProcessHost host, FakeRuntimeObservationPort port) = Create( + [ + new LocalProcessInfo(52, "alpha-worker", "C:\\fixtures\\alpha-worker.exe"), + new LocalProcessInfo(43, "alpha-server", "C:\\fixtures\\alpha-server.exe"), + new LocalProcessInfo(44, "beta", "C:\\fixtures\\beta.exe") + ]); + + LocalProcessEnumerationResult result = client.GetLocalProcesses( + new LocalProcessEnumerationRequest(1, "ALPHA"), + TestContext.Current.CancellationToken); + + Assert.True(result.IsTruncated); + LocalProcessSnapshot snapshot = Assert.Single(result.Processes); + Assert.Equal(new LocalProcessId(43), snapshot.Id); + Assert.Equal("alpha-server", snapshot.Name); + Assert.Equal("C:\\fixtures\\alpha-server.exe", snapshot.ExecutablePath); + Assert.Equal(1, host.GetLocalProcessesCalls); + Assert.Empty(port.Calls); + Assert.Empty(port.TargetCalls); + } + + [Fact] + public void TryGetLocalProcessesReportsACancellationAsNotStartedBeforeReadingTheLocalCatalog() + { + (ProcessClient client, FakeLocalProcessHost host, _) = Create([]); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = client.TryGetLocalProcesses( + new LocalProcessEnumerationRequest(1), + out LocalProcessEnumerationResult result, + out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Processes.GetLocalProcesses", failure.Operation); + Assert.Equal(0, host.GetLocalProcessesCalls); + Assert.IsType(Assert.ThrowsAny(() => + client.GetLocalProcesses(new LocalProcessEnumerationRequest(1), cancellation.Token))); + } + + [Fact] + public void TryGetLocalProcessesRejectsAnInvalidRequestBeforeReadingTheLocalCatalog() + { + (ProcessClient client, FakeLocalProcessHost host, _) = Create([]); + + Assert.Throws(() => + client.TryGetLocalProcesses(default, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(0, host.GetLocalProcessesCalls); + } + + [Fact] + public void TryGetLocalProcessesMapsLocalCatalogFailuresWithoutClaimingTargetState() + { + (ProcessClient client, FakeLocalProcessHost host, _) = Create([]); + host.GetLocalProcessesException = new InvalidOperationException("fixture enumeration failed"); + + bool succeeded = client.TryGetLocalProcesses( + new LocalProcessEnumerationRequest(1), + out LocalProcessEnumerationResult result, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Processes.GetLocalProcesses", failure.Operation); + Assert.Equal(1, host.GetLocalProcessesCalls); + } + + [Fact] + public void LocalProcessesRemainReadableAfterTheClientActivationExpires() + { + FakeLocalProcessHost host = new([new LocalProcessInfo(43, "fixture", "C:\\fixtures\\fixture.exe")]); + FakeRuntimeObservationPort port = new(); + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + ProcessClient client = new(new RefusingDispatcher(), host, port, port, lifetime); + context.IsCurrent = false; + + LocalProcessSnapshot snapshot = Assert.Single(client.GetLocalProcesses( + new LocalProcessEnumerationRequest(1), TestContext.Current.CancellationToken).Processes); + + Assert.Throws(() => lifetime.ThrowIfInactive("Test.Stale")); + Assert.Equal(new LocalProcessId(43), snapshot.Id); + Assert.Equal("fixture", snapshot.Name); + Assert.Empty(port.TargetCalls); + } + + private static (ProcessClient Client, FakeLocalProcessHost Host, FakeRuntimeObservationPort Port) Create( + IReadOnlyList processes) + { + FakeLocalProcessHost host = new(processes); + FakeRuntimeObservationPort port = new(); + ProcessClient client = new(new RefusingDispatcher(), host, port, port, + new TargetSelectionLifetime(static _ => + { + })); + return (client, host, port); + } + + private sealed class FakeLocalProcessHost(IReadOnlyList processes) : IProcessHost + { + internal int GetLocalProcessesCalls + { + get; + private set; + } + + internal Exception? GetLocalProcessesException + { + get; + set; + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + process = default; + return false; + } + + public IReadOnlyList GetLocalProcesses() + { + GetLocalProcessesCalls++; + if (GetLocalProcessesException is { } exception) + { + throw exception; + } + + return processes; + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + return []; + } + } + + /// A dispatcher that fails the test when used: the local catalog never dispatches to Cheat Engine. + private sealed class RefusingDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("The local process catalog must not dispatch to Cheat Engine."); + } + + public bool TryInvoke(Func callback, out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("The local process catalog must not dispatch to Cheat Engine."); + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("The local process catalog must not dispatch to Cheat Engine."); + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("The local process catalog must not dispatch to Cheat Engine."); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs index 4829c70..e125604 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs @@ -7,7 +7,10 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Tests.Domains; @@ -18,13 +21,13 @@ public void RefreshKeepsTheSelectionEpochForTheSamePidAndArchitecture() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); Assert.Equal(initial.Id, refreshed.Id); - Assert.Equal(initial.TargetArchitecture, refreshed.TargetArchitecture); + Assert.Equal(initial.Architecture, refreshed.Architecture); Assert.Equal(0, initial.SelectionEpoch); Assert.Equal(initial.SelectionEpoch, refreshed.SelectionEpoch); } @@ -35,8 +38,8 @@ public void RefreshChangesPidAdvancesSelectionEpochAndDisposesTheOldTargetLease( FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); RecordingDisposable lease = new(); selectionLifetime.Track(lease, initial.SelectionEpoch); host.OpenedProcessId = 43; @@ -46,7 +49,7 @@ public void RefreshChangesPidAdvancesSelectionEpochAndDisposesTheOldTargetLease( Assert.Equal(new TargetProcessId(43), refreshed.Id); Assert.Equal(1, refreshed.SelectionEpoch); Assert.Equal(1, lease.DisposeCount); - Assert.Throws(() => + Assert.Throws(() => selectionLifetime.ThrowIfExpired(initial.SelectionEpoch, "Test.TargetLease")); } @@ -55,20 +58,20 @@ public void RefreshChangesArchitectureForTheSamePidAndAdvancesSelectionEpoch() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X86); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); host.TargetArchitecture = CheatEngineArchitecture.X64; ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); Assert.Equal(new TargetProcessId(42), refreshed.Id); - Assert.Equal(CheatEngineArchitecture.X64, refreshed.TargetArchitecture); + Assert.Equal(CheatEngineArchitecture.X64, refreshed.Architecture); Assert.Equal(1, refreshed.SelectionEpoch); Assert.NotEqual(initial.SelectionEpoch, refreshed.SelectionEpoch); } [Fact] - public void TryGetCurrentReportsTargetNotAttachedAndInvalidatesTheKnownSelectionWhenCeDetaches() + public void TryGetCurrentProcessReportsTargetNotAttachedAndInvalidatesTheKnownSelectionWhenCeDetaches() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); using ControlledCoreLifetimeContext activationContext = new(); @@ -77,11 +80,13 @@ public void TryGetCurrentReportsTargetNotAttachedAndInvalidatesTheKnownSelection ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); host.OpenedProcessId = 0; - bool succeeded = client.TryGetCurrent( + bool succeeded = client.TryGetCurrentProcess( out ProcessSnapshot snapshot, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -89,13 +94,13 @@ public void TryGetCurrentReportsTargetNotAttachedAndInvalidatesTheKnownSelection Assert.False(succeeded); Assert.Equal(default, snapshot); Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); - Assert.Equal("Processes.GetCurrent", failure.Operation); + Assert.Equal("Processes.GetCurrentProcess", failure.Operation); Assert.Equal(1, selectionLifetime.Epoch); - Assert.Throws(() => + Assert.Throws(() => selectionLifetime.ThrowIfExpired(initial.SelectionEpoch, "Test.TargetLease")); CheatEngineOperationException exception = Assert.Throws(() => - client.GetCurrent(TestContext.Current.CancellationToken)); + client.GetCurrentProcess(TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.TargetNotAttached, exception.Failure.Kind); } @@ -109,8 +114,10 @@ public void TryRefreshRetainsTheCheatEngineSelectionWhenLocalMetadataDisappears( ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); RecordingDisposable lease = new(); selectionLifetime.Track(lease, initial.SelectionEpoch); host.LocalProcesses.Remove(initial.Id.Value); @@ -138,7 +145,7 @@ public void TryRefreshRetainsTheCheatEngineSelectionWhenLocalMetadataDisappears( } [Fact] - public void TryGetCurrentReportsInvalidHostResultForInconsistentLocalMetadata() + public void TryGetCurrentProcessReportsInvalidHostResultForInconsistentLocalMetadata() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); host.LocalProcesses[42] = new LocalProcessInfo(41, "fixture", "C:\\fixtures\\fixture.exe"); @@ -148,9 +155,11 @@ public void TryGetCurrentReportsInvalidHostResultForInconsistentLocalMetadata() ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, selectionLifetime); - bool succeeded = client.TryGetCurrent( + bool succeeded = client.TryGetCurrentProcess( out ProcessSnapshot snapshot, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -158,52 +167,252 @@ public void TryGetCurrentReportsInvalidHostResultForInconsistentLocalMetadata() Assert.False(succeeded); Assert.Equal(default, snapshot); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Processes.GetCurrent", failure.Operation); + Assert.Equal("Processes.GetCurrentProcess", failure.Operation); Assert.IsType(failure.Exception); Assert.Equal(0, selectionLifetime.Epoch); } [Fact] - public void TryGetCurrentRethrowsUnexpectedHostExceptions() + public void TryGetCurrentProcessReturnsUnexpectedHostExceptionsAsClassifiedFailures() { + // C-CORE-A's SdkBoundary rule (F15): a fault of a Client-internal host call never crosses a Try method. FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); ObjectDisposedException expected = new("fixture process host"); - host.GetOpenedProcessIdException = expected; + host.Port.TargetFault = expected; using ControlledCoreLifetimeContext activationContext = new(); using CoreLifetime activationLifetime = new(activationContext); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, selectionLifetime); - ObjectDisposedException actual = Assert.Throws(() => - client.TryGetCurrent(out _, out _, TestContext.Current.CancellationToken)); + bool succeeded = client.TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); - Assert.Same(expected, actual); + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal("Processes.GetCurrentProcess", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Same(expected, failure.Exception); + Assert.Equal(0, selectionLifetime.Epoch); + } + + [Fact] + public void TryAttachReturnsAFaultOfTheAttachCallWithAnUnknownHostEffect() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + LuaException expected = new("openProcess failed"); + host.Port.SelectFault = expected; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + bool succeeded = client.TryAttach(new TargetProcessId(43), out ProcessSnapshot snapshot, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal("Processes.Attach", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Same(expected, failure.Exception); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void RefreshWithAChangedProcessWidthForTheSamePidAdvancesTheSelectionEpoch() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.Unknown); + host.IsX86 = false; + host.IsArm = false; + host.Is64Bit = false; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + host.Is64Bit = true; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, initial.Architecture); + Assert.Equal(PointerSize.Bit32, initial.Bitness); + Assert.Equal(PointerSize.Bit64, refreshed.Bitness); + Assert.Equal(initial.SelectionEpoch + 1, refreshed.SelectionEpoch); + Assert.Equal(1, lease.DisposeCount); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void RefreshWithTransientlyUnknownIsaFactsKeepsTheSelectionEpochAndTheLastKnownFacts() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + // A raising target fact narrows the observation: the ISA is unknown for this read, the PID and bitness are not. + host.Port.TargetStatus = TargetObservations.LuaFailure; + + ProcessSnapshot transient = client.Refresh(TestContext.Current.CancellationToken); + host.Port.TargetStatus = ProcessOperationStatus.Success; + ProcessSnapshot recovered = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(initial.SelectionEpoch, transient.SelectionEpoch); + Assert.Equal(CheatEngineArchitecture.X64, transient.Architecture); + Assert.Equal(PointerSize.Bit64, transient.Bitness); + Assert.Equal(initial.SelectionEpoch, recovered.SelectionEpoch); + Assert.Equal(CheatEngineArchitecture.X64, recovered.Architecture); + Assert.Equal(0, lease.DisposeCount); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void RefreshAfterAPidChangeAdvancesTheEpochEvenWhenTheIsaIsUnknown() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + host.OpenedProcessId = 43; + host.IsX86 = null; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(43), refreshed.Id); + Assert.Equal(initial.SelectionEpoch + 1, refreshed.SelectionEpoch); + Assert.Equal(CheatEngineArchitecture.Unknown, refreshed.Architecture); + Assert.Equal(PointerSize.Bit64, refreshed.Bitness); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void CurrentSnapshotStoresTheObservedProcessWidthWhenTheIsaIsUnknown() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.IsX86 = false; + host.IsArm = false; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + ProcessSnapshot snapshot = client.GetCurrentProcess(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Architecture); + Assert.Equal(PointerSize.Bit64, snapshot.Bitness); + Assert.Equal([nameof(ITargetObservationPort.ObserveTargetArchitecture)], host.Port.TargetCalls); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void CurrentSnapshotReadsNoArchitectureFactWithoutASelectedProcess() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.OpenedProcessId = 0; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + bool succeeded = client.TryGetCurrentProcess(out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); + // The SDK reads no target fact without a selected process, and the Client re-reads nothing. + Assert.Equal([nameof(ITargetObservationPort.ObserveTargetArchitecture)], host.Port.TargetCalls); } [Fact] - public void TryGetCurrentHonorsCancellationBeforeProductionDispatchAdmission() + public void TryGetCurrentProcessKeepsThePidAndTheBitnessWhenOneTargetFactRaises() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.TargetStatus = TargetObservations.LuaFailure; using ControlledCoreLifetimeContext activationContext = new(); using CoreLifetime activationLifetime = new(activationContext); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, + selectionLifetime); + + bool succeeded = client.TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new TargetProcessId(42), snapshot.Id); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Architecture); + Assert.Equal(PointerSize.Bit64, snapshot.Bitness); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void CurrentSnapshotReportsATargetChangeWhenThePidChangesDuringObservation() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.TargetStatus = ProcessOperationStatus.TargetChanged; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + bool succeeded = client.TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(0, selectionLifetime.Epoch); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void CurrentSnapshotReportsAFailedClosingReadAsALuaErrorRatherThanAsATargetChange() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.TargetStatus = TargetObservations.LuaFailure; + host.Port.CurrentReads = [(ProcessOperationStatus.Success, 42), (TargetObservations.LuaFailure, 0)]; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + bool succeeded = client.TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.DoesNotContain("changed", failure.Message, StringComparison.Ordinal); + Assert.Equal(0, selectionLifetime.Epoch); + } + + [Fact] + public void TryGetCurrentProcessHonorsCancellationBeforeProductionDispatchAdmission() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using ControlledCoreLifetimeContext activationContext = new(); + using CoreLifetime activationLifetime = new(activationContext); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new( + new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), + host, + host.Port, + host.Port, selectionLifetime); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - bool succeeded = client.TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + bool succeeded = client.TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellation.Token); Assert.False(succeeded); Assert.Equal(default, snapshot); Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); - Assert.Equal(0, host.GetOpenedProcessIdCalls); + Assert.Empty(host.Port.TargetCalls); } [Fact] @@ -230,6 +439,8 @@ public void AttachVerifiesThePidSelectedByCheatEngineBeforeReturningTheSnapshot( ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, selectionLifetime); bool succeeded = client.TryAttach( @@ -242,7 +453,7 @@ public void AttachVerifiesThePidSelectedByCheatEngineBeforeReturningTheSnapshot( Assert.Equal(default, snapshot); Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); Assert.Equal("Processes.Attach", failure.Operation); - Assert.Equal([43L], host.OpenProcessCalls); + Assert.Equal([43], host.Port.SelectCalls); } [Fact] @@ -257,8 +468,10 @@ public void TryAttachReturnsTheSelectedCheatEngineTargetWhenLocalMetadataDisappe ProcessClient client = new( new SdkMainThreadDispatcher(activationLifetime, new InlineMainThreadInvoker()), host, + host.Port, + host.Port, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); RecordingDisposable lease = new(); selectionLifetime.Track(lease, initial.SelectionEpoch); @@ -273,7 +486,7 @@ public void TryAttachReturnsTheSelectedCheatEngineTargetWhenLocalMetadataDisappe Assert.Equal(new TargetProcessId(43), snapshot.Id); Assert.Null(snapshot.Name); Assert.Null(snapshot.ExecutablePath); - Assert.Equal([43L], host.OpenProcessCalls); + Assert.Equal([43], host.Port.SelectCalls); Assert.Equal(1, selectionLifetime.Epoch); Assert.Equal(1, lease.DisposeCount); } @@ -284,8 +497,8 @@ public void TryAttachSelectsTheRequestedPidAndAdvancesAnAlreadyObservedSelection FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); RecordingDisposable lease = new(); selectionLifetime.Track(lease, initial.SelectionEpoch); @@ -300,7 +513,7 @@ public void TryAttachSelectsTheRequestedPidAndAdvancesAnAlreadyObservedSelection Assert.Equal(new TargetProcessId(43), snapshot.Id); Assert.Equal(1, snapshot.SelectionEpoch); Assert.Equal(1, lease.DisposeCount); - Assert.Equal([43L], host.OpenProcessCalls); + Assert.Equal([43], host.Port.SelectCalls); } [Fact] @@ -308,7 +521,7 @@ public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyP { FakeProcessHost noMatchHost = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); using TargetSelectionLifetime noMatchLifetime = CreateSelectionLifetime(); - ProcessClient noMatchClient = new(new InlineDispatcher(), noMatchHost, noMatchLifetime); + ProcessClient noMatchClient = new(new InlineDispatcher(), noMatchHost, noMatchHost.Port, noMatchHost.Port, noMatchLifetime); bool noMatchSucceeded = noMatchClient.TryAttachExactName( "absent.exe", @@ -319,7 +532,7 @@ public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyP Assert.False(noMatchSucceeded); Assert.Equal(default, noMatchSnapshot); Assert.Equal(CheatEngineFailureKind.NotFound, noMatchFailure.Kind); - Assert.Empty(noMatchHost.OpenProcessCalls); + Assert.Empty(noMatchHost.Port.SelectCalls); FakeProcessHost ambiguousHost = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); ambiguousHost.NameMatches["fixture"] = @@ -328,7 +541,7 @@ public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyP new LocalProcessInfo(44, "fixture", "C:\\fixtures\\two.exe") ]; using TargetSelectionLifetime ambiguousLifetime = CreateSelectionLifetime(); - ProcessClient ambiguousClient = new(new InlineDispatcher(), ambiguousHost, ambiguousLifetime); + ProcessClient ambiguousClient = new(new InlineDispatcher(), ambiguousHost, ambiguousHost.Port, ambiguousHost.Port, ambiguousLifetime); bool ambiguousSucceeded = ambiguousClient.TryAttachExactName( "fixture.exe", @@ -339,24 +552,27 @@ public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyP Assert.False(ambiguousSucceeded); Assert.Equal(default, ambiguousSnapshot); Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, ambiguousFailure.Kind); - Assert.Empty(ambiguousHost.OpenProcessCalls); + Assert.Empty(ambiguousHost.Port.SelectCalls); } [Fact] public void TryAttachExactNameRejectsAStaleActivationBeforeLocalProcessDiscovery() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); - using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using ControlledCoreLifetimeContext context = new() + { + IsCurrent = false + }; using CoreLifetime lifetime = new(context); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime, lifetime.ThrowIfInactive); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime, lifetime.ThrowIfInactive); CheatEngineActivationExpiredException exception = Assert.Throws(() => client.TryAttachExactName("fixture.exe", out _, out _, TestContext.Current.CancellationToken)); Assert.Equal("Processes.AttachExactName", exception.Failure.Operation); Assert.Equal(0, host.FindProcessesByExactNameCalls); - Assert.Empty(host.OpenProcessCalls); + Assert.Empty(host.Port.SelectCalls); } [Fact] @@ -366,7 +582,7 @@ public void TryAttachExactNameHonorsCancellationBeforeLocalProcessDiscovery() using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime, lifetime.ThrowIfInactive); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime, lifetime.ThrowIfInactive); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); @@ -385,7 +601,7 @@ public void TryAttachExactNameMapsLocalCatalogFailuresBeforeChangingTheCheatEngi FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); host.FindProcessesByExactNameException = new InvalidOperationException("fixture discovery failed"); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); bool succeeded = client.TryAttachExactName("fixture.exe", out ProcessSnapshot snapshot, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -395,7 +611,7 @@ public void TryAttachExactNameMapsLocalCatalogFailuresBeforeChangingTheCheatEngi Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); Assert.Equal("Processes.AttachExactName", failure.Operation); Assert.Equal(1, host.FindProcessesByExactNameCalls); - Assert.Empty(host.OpenProcessCalls); + Assert.Empty(host.Port.SelectCalls); } [Fact] @@ -405,14 +621,14 @@ public void AttachExactNameUsesTheSingleExactCandidateAndReturnsCopiedMetadata() host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture", "C:\\fixtures\\fixture.exe"); host.NameMatches["fixture"] = [host.LocalProcesses[43]]; using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); ProcessSnapshot snapshot = client.AttachExactName("fixture.exe", TestContext.Current.CancellationToken); Assert.Equal(new TargetProcessId(43), snapshot.Id); Assert.Equal("fixture", snapshot.Name); Assert.Equal("C:\\fixtures\\fixture.exe", snapshot.ExecutablePath); - Assert.Equal([43L], host.OpenProcessCalls); + Assert.Equal([43], host.Port.SelectCalls); } [Theory] @@ -423,148 +639,293 @@ public void ExactNameAttachmentRejectsPathsAndInvalidNamesBeforeProcessDiscovery { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); Assert.Throws(() => client.TryAttachExactName(name, out _, out _, TestContext.Current.CancellationToken)); - Assert.Empty(host.OpenProcessCalls); + Assert.Empty(host.Port.SelectCalls); } - [Fact] - public void TryAttachForegroundIsCapabilityGatedWithoutChangingTheObservedSelection() + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(ProcessOperationStatusKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(ProcessOperationStatusKind.TargetChanged, CheatEngineFailureKind.TargetChanged)] + [InlineData(ProcessOperationStatusKind.FileAsProcessTarget, CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData(ProcessOperationStatusKind.SelectionNotConfirmed, CheatEngineFailureKind.OperationRejected)] + [InlineData(ProcessOperationStatusKind.Unknown, CheatEngineFailureKind.IndeterminateHostResult)] + public void TryGetCurrentProcessReportsEveryStatusThatEstablishesNoTargetWithItsOwnKind( + ProcessOperationStatusKind status, CheatEngineFailureKind expected) { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.TargetStatus = TargetObservations.Status(status); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); - bool succeeded = client.TryAttachForeground( - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, + bool succeeded = client.TryGetCurrentProcess(out ProcessSnapshot snapshot, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); Assert.Equal(default, snapshot); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal(42, host.OpenedProcessId); - Assert.Empty(host.OpenProcessCalls); - Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + Assert.Equal(expected, failure.Kind); + Assert.Equal("Processes.GetCurrentProcess", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Null(failure.Exception); } [Fact] - public void AttachForegroundThrowsTheStableCapabilityUnavailableFailure() + [Trait("Qualification", "Q32")] + public void AFileOpenedAsAProcessInvalidatesTheKnownProcessSelection() { + // A file opened as a process has no process identity: the earlier selection no longer holds (audit A12-07). FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + host.Port.TargetStatus = ProcessOperationStatus.FileAsProcessTarget; - CheatEngineOperationException exception = Assert.Throws(() => - client.AttachForeground(TestContext.Current.CancellationToken)); + bool succeeded = client.TryRefresh(out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - Assert.Equal("Processes.AttachForeground", exception.Failure.Operation); - Assert.Empty(host.OpenProcessCalls); + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.TargetIdentityUnavailable, failure.Kind); + Assert.Equal(1, selectionLifetime.Epoch); + Assert.Equal(1, lease.DisposeCount); + } + + [Theory] + [Trait("Qualification", "Q30.a")] + [InlineData(ProcessOperationStatusKind.SelectionNotConfirmed, CheatEngineFailureKind.OperationRejected)] + [InlineData(ProcessOperationStatusKind.TargetNotAttached, CheatEngineFailureKind.TargetNotAttached)] + [InlineData(ProcessOperationStatusKind.FileAsProcessTarget, CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData(ProcessOperationStatusKind.TargetChanged, CheatEngineFailureKind.TargetChanged)] + [InlineData(ProcessOperationStatusKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(ProcessOperationStatusKind.ProtectedLuaFailure, CheatEngineFailureKind.LuaError)] + [InlineData(ProcessOperationStatusKind.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(ProcessOperationStatusKind.Unknown, CheatEngineFailureKind.IndeterminateHostResult)] + public void TryAttachReportsEveryRefusedSelectionWithItsKindAndAnUnknownHostEffect( + ProcessOperationStatusKind status, CheatEngineFailureKind expected) + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.SelectStatus = TargetObservations.Status(status); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + bool succeeded = client.TryAttach(new TargetProcessId(43), out ProcessSnapshot snapshot, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(expected, failure.Kind); + Assert.Equal("Processes.Attach", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Equal([43], host.Port.SelectCalls); } [Fact] - public void TryCreateIsCapabilityGatedWithoutStartingOrSelectingAnyProcess() + [Trait("Qualification", "Q30.a")] + public void ARefusedAttachStillMovesTheSelectionEpochToWhatCheatEngineNowSelects() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.SelectedAfterOpenOverride = 44; + host.LocalProcesses[44] = new LocalProcessInfo(44, "fixture-c", "C:\\fixtures\\fixture-c.exe"); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); - bool succeeded = client.TryCreate( - new ProcessStartRequest("C:\\fixtures\\target.exe"), - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, + bool succeeded = client.TryAttach(new TargetProcessId(43), out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); - Assert.Equal(default, snapshot); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal(42, host.OpenedProcessId); - Assert.Empty(host.OpenProcessCalls); - Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(1, selectionLifetime.Epoch); + Assert.Equal(1, lease.DisposeCount); + Assert.Equal(new TargetProcessId(44), client.GetCurrentProcess(TestContext.Current.CancellationToken).Id); } [Fact] - public void TryCreateRejectsTheDefaultRequestBeforeHostAdmission() + [Trait("Qualification", "Q30.b")] + public void TheSelectionEpochAdvancesWhenTheSamePidDenotesAnotherIncarnation() { + // The PID alone can be reused: the SDK's creation-time observation tells the two processes apart. FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.Incarnation = TargetObservations.Incarnation(42, 1_000); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + host.Port.Incarnation = TargetObservations.Incarnation(42, 2_000); - Assert.Throws(() => client.TryCreate( - default, - out _, - out _, - TestContext.Current.CancellationToken)); + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); - Assert.Empty(host.OpenProcessCalls); + Assert.Equal(new TargetProcessId(42), refreshed.Id); + Assert.Equal(initial.SelectionEpoch + 1, refreshed.SelectionEpoch); + Assert.Equal(1, lease.DisposeCount); + Assert.Equal(1, host.Port.Count(nameof(IRuntimeObservationPort.ObserveSelection))); + Assert.Equal(1, host.Port.Count(nameof(IRuntimeObservationPort.ValidateSelection))); } [Fact] - public void TryPauseIsCapabilityGatedWithoutChangingTheObservedSelection() + [Trait("Qualification", "Q30.b")] + public void AnUnchangedIncarnationKeepsTheSelectionEpoch() { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.Incarnation = TargetObservations.Incarnation(42, 1_000); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); - bool succeeded = client.TryPause( - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + ProcessSnapshot first = client.Refresh(TestContext.Current.CancellationToken); + ProcessSnapshot second = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(0, initial.SelectionEpoch); + Assert.Equal(0, first.SelectionEpoch); + Assert.Equal(0, second.SelectionEpoch); + Assert.Equal(2, host.Port.Count(nameof(IRuntimeObservationPort.ValidateSelection))); + } + + [Theory] + [Trait("Qualification", "Q30.b")] + [InlineData(TargetSelectionObservationStatus.CurrentTargetUnqualified)] + [InlineData(TargetSelectionObservationStatus.LuaFailure)] + [InlineData(TargetSelectionObservationStatus.GlobalUnavailable)] + [InlineData(TargetSelectionObservationStatus.InvalidResult)] + [InlineData(TargetSelectionObservationStatus.CurrentTargetBackendUnknown)] + public void AnUnavailableIdentityReadKeepsTheSelectionEpoch(TargetSelectionObservationStatus status) + { + // No comparable incarnation is no evidence of a change: leases stay valid. + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.Incarnation = TargetObservations.Incarnation(42, 1_000); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + host.Port.SelectionStatus = status; + + bool succeeded = client.TryRefresh(out ProcessSnapshot refreshed, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + host.Port.SelectionStatus = null; + host.Port.Incarnation = TargetObservations.Incarnation(42, 2_000); + ProcessSnapshot reused = client.Refresh(TestContext.Current.CancellationToken); - Assert.False(succeeded); - Assert.Equal(default, snapshot); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal(42, host.OpenedProcessId); - Assert.Empty(host.OpenProcessCalls); - Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(initial.SelectionEpoch, refreshed.SelectionEpoch); + // The last known incarnation was kept, so a later reuse is still detected. + Assert.Equal(initial.SelectionEpoch + 1, reused.SelectionEpoch); } - [Fact] - public void TryResumeExecutionIsCapabilityGatedWithoutChangingTheObservedSelection() + [Theory] + [Trait("Qualification", "Q30.a")] + [InlineData(TargetSelectionObservationStatus.NoTargetSelected, false)] + [InlineData(TargetSelectionObservationStatus.CurrentTargetFileAsProcess, false)] + [InlineData(TargetSelectionObservationStatus.CurrentTargetRemoteBackend, false)] + [InlineData(TargetSelectionObservationStatus.NoTargetSelected, true)] + [InlineData(TargetSelectionObservationStatus.CurrentTargetRemoteBackend, true)] + public void ASelectionThatMovesBetweenTheFactsAndTheIdentityReadIsATargetChange( + TargetSelectionObservationStatus status, bool withKnownIncarnation) { FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.Incarnation = TargetObservations.Incarnation(42, 1_000); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + if (withKnownIncarnation) + { + _ = client.GetCurrentProcess(TestContext.Current.CancellationToken); + } - bool succeeded = client.TryResumeExecution( - out ProcessSnapshot snapshot, - out CheatEngineFailure failure, + host.Port.SelectionStatus = status; + + bool succeeded = client.TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); Assert.Equal(default, snapshot); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal(42, host.OpenedProcessId); - Assert.Empty(host.OpenProcessCalls); - Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(0, selectionLifetime.Epoch); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(TargetBackend.CEServer)] + [InlineData(TargetBackend.Unknown)] + public void ATargetThatIsNotALocalProcessGetsNoLocalMetadataAndNoIncarnationRead(TargetBackend backend) + { + // A local PID and creation time do not describe a PID served by CEServer or of an unknown backend (A12-05). + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Backend = backend; + host.Port.Incarnation = TargetObservations.Incarnation(42, 1_000); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + ProcessSnapshot snapshot = client.GetCurrentProcess(TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(42), snapshot.Id); + Assert.Equal(backend, snapshot.Backend); + Assert.Null(snapshot.Name); + Assert.Null(snapshot.ExecutablePath); + Assert.Null(snapshot.StartTimeUtc); + Assert.Equal(0, host.Port.Count(nameof(IRuntimeObservationPort.ObserveSelection))); + Assert.Equal(0, host.Port.Count(nameof(IRuntimeObservationPort.ValidateSelection))); } [Fact] - public void TryGetPauseStateIsCapabilityGatedWithoutChangingTheObservedSelection() + [Trait("Qualification", "Q32")] + public void ALocalSnapshotCarriesItsBackendBitnessConfiguredSizeAndIncarnationStartTime() { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X86); + long startedAtUtcTicks = new DateTime(2026, 9, 24, 10, 30, 0, DateTimeKind.Utc).Ticks; + host.Port.Incarnation = TargetObservations.Incarnation(42, startedAtUtcTicks); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + + ProcessSnapshot snapshot = client.GetCurrentProcess(TestContext.Current.CancellationToken); + + Assert.Equal(TargetBackend.LocalProcess, snapshot.Backend); + Assert.Equal(CheatEngineArchitecture.X86, snapshot.Architecture); + Assert.Equal(PointerSize.Bit32, snapshot.Bitness); + Assert.Equal(4, snapshot.ConfiguredPointerSizeBytes); + Assert.False(snapshot.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(new DateTimeOffset(startedAtUtcTicks, TimeSpan.Zero), snapshot.StartTimeUtc); + Assert.Equal("fixture", snapshot.Name); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void TheSnapshotReportsAConfiguredPointerSizeThatDiffersFromTheBitnessWithoutUsingIt() + { + // Spike C3 D3: setPointerSize(4) on an x64 target leaves targetIs64Bit true and readPointer 8 bytes wide. FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.Port.Target = TargetObservations.Create(configuredPointerSizeBytes: 4); using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); - ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); - ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); - bool succeeded = client.TryGetPauseState( - out ProcessPauseSnapshot snapshot, - out CheatEngineFailure failure, - TestContext.Current.CancellationToken); + ProcessSnapshot snapshot = client.GetCurrentProcess(TestContext.Current.CancellationToken); - Assert.False(succeeded); - Assert.Equal(default, snapshot); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal(42, host.OpenedProcessId); - Assert.Empty(host.OpenProcessCalls); - Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + Assert.Equal(PointerSize.Bit64, snapshot.Bitness); + Assert.Equal(4, snapshot.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit32, snapshot.ConfiguredPointerSize); + Assert.True(snapshot.ConfiguredPointerSizeDiffersFromBitness); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void ABackendChangeAtTheSamePidAdvancesTheSelectionEpoch() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, host.Port, host.Port, selectionLifetime); + ProcessSnapshot initial = client.GetCurrentProcess(TestContext.Current.CancellationToken); + host.Backend = TargetBackend.CEServer; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(initial.SelectionEpoch + 1, refreshed.SelectionEpoch); + Assert.Null(refreshed.Name); } private static TargetSelectionLifetime CreateSelectionLifetime() @@ -588,35 +949,31 @@ public void Dispose() } } + /// + /// The local catalog and attach call of a process host, paired with an observation port whose selected target + /// follows and the ISA facts. + /// private sealed class FakeProcessHost : IProcessHost { - internal Dictionary LocalProcesses - { - get; - } = []; + private bool _is64Bit; + private bool? _isArm; + private bool? _isX86; - internal Dictionary> NameMatches + internal FakeRuntimeObservationPort Port { get; - } = - new(StringComparer.OrdinalIgnoreCase); + } = new(); - internal List OpenProcessCalls + internal Dictionary LocalProcesses { get; } = []; - internal int GetLocalProcessesCalls - { - get; - private set; - } - - internal int GetOpenedProcessIdCalls + internal Dictionary> NameMatches { get; - private set; - } + } = + new(StringComparer.OrdinalIgnoreCase); internal int FindProcessesByExactNameCalls { @@ -624,62 +981,93 @@ internal int FindProcessesByExactNameCalls private set; } - internal Exception? GetLocalProcessesException + internal Exception? FindProcessesByExactNameException { get; set; } - internal Exception? GetOpenedProcessIdException + internal Action? AfterOpenProcess { get; set; } - internal Exception? FindProcessesByExactNameException + /// Gets or sets Cheat Engine's selected PID; zero means that no target is selected. + internal long OpenedProcessId { get; - set; + set + { + field = value; + Synchronize(); + } } - internal Action? AfterOpenProcess + internal long? SelectedAfterOpenOverride { get; set; } - internal long OpenedProcessId + /// Gets or sets how Cheat Engine reaches the selected target (a local process by default). + internal TargetBackend Backend { get; - set; - } + set + { + field = value; + Synchronize(); + } + } = TargetBackend.LocalProcess; - internal long? SelectedAfterOpenOverride + /// Sets the ISA-family and 64-bit facts that Cheat Engine reports for the architecture. + internal CheatEngineArchitecture TargetArchitecture { - get; - set; + set + { + _isX86 = value is CheatEngineArchitecture.X86 or CheatEngineArchitecture.X64; + _isArm = value is CheatEngineArchitecture.Arm32 or CheatEngineArchitecture.Arm64; + _is64Bit = value is CheatEngineArchitecture.X64 or CheatEngineArchitecture.Arm64; + Synchronize(); + } } - internal CheatEngineArchitecture TargetArchitecture + internal bool Is64Bit { - get; - set; + get => _is64Bit; + set + { + _is64Bit = value; + Synchronize(); + } } - public long GetOpenedProcessId() + /// Gets or sets targetIsX86; means that the global is absent. + internal bool? IsX86 { - GetOpenedProcessIdCalls++; - if (GetOpenedProcessIdException is { } exception) + get => _isX86; + set { - throw exception; + _isX86 = value; + Synchronize(); } + } - return OpenedProcessId; + /// Gets or sets targetIsArm; means that the global is absent. + internal bool? IsArm + { + get => _isArm; + set + { + _isArm = value; + Synchronize(); + } } - public void OpenProcess(long processId) + /// What Cheat Engine selects when the Client asks for a PID (the fake port's attach call). + private void Select(int processId) { - OpenProcessCalls.Add(processId); OpenedProcessId = SelectedAfterOpenOverride ?? processId; AfterOpenProcess?.Invoke(this); } @@ -691,13 +1079,7 @@ public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) public IReadOnlyList GetLocalProcesses() { - GetLocalProcessesCalls++; - if (GetLocalProcessesException is { } exception) - { - throw exception; - } - - return LocalProcesses.Values.ToArray(); + return [.. LocalProcesses.Values]; } public IReadOnlyList FindProcessesByExactName(string processName) @@ -711,20 +1093,29 @@ public IReadOnlyList FindProcessesByExactName(string processNa return NameMatches.TryGetValue(processName, out IReadOnlyList? matches) ? matches : []; } - public CheatEngineArchitecture GetTargetArchitecture() - { - return TargetArchitecture; - } - internal static FakeProcessHost CreateSelected(int processId, CheatEngineArchitecture architecture) { - FakeProcessHost host = new() { OpenedProcessId = processId, TargetArchitecture = architecture }; + FakeProcessHost host = new() + { + OpenedProcessId = processId, + TargetArchitecture = architecture + }; + host.Port.OnSelect = host.Select; host.LocalProcesses[processId] = new LocalProcessInfo( processId, "fixture", "C:\\fixtures\\fixture.exe"); return host; } + + private void Synchronize() + { + Port.TargetStatus = OpenedProcessId == 0 + ? ProcessOperationStatus.TargetNotAttached + : ProcessOperationStatus.Success; + Port.Target = TargetObservations.Create((int) Math.Max(OpenedProcessId, 1), _is64Bit, _isX86, _isArm, + backend: Backend); + } } private sealed class InlineDispatcher : ICheatEngineDispatcher @@ -772,7 +1163,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -783,7 +1174,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs index 5ad9334..7bae30a 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs @@ -1,28 +1,36 @@ using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Qualification; +using CheatEngine.Client.Core.Tests.TestSupport; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; -using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Processes; using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Tests.Domains; public sealed class RuntimeClientTests { + private const string SdkVersion = "2.0.0"; + private const string SdkCommit = "325c47b573f8bd39a247f1d0101f110fa36c1696"; + + private const string SdkContentHash = + "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ=="; + + private const string SdkSupportedMajor = "2"; + + // Another CheatEngine.SDK build loaded next to this Client: a prerelease of the next major. + private const string OtherSdkInformationalVersion = "3.0.0-alpha.0.1+0123456789abcdef0123456789abcdef01234567"; + [Fact] - public void SnapshotReportsOnlyTheObservedCeLineAndIndependentCapabilities() + public void SnapshotReportsTheFactsOfTheSdkRuntimeSnapshotInOneObservation() { InlineDispatcher dispatcher = new(); - FakeRuntimeProbe probe = new() - { - ReportedVersion = 7.7d, - SystemArchitectureCode = 1, - TargetAbiCode = 0, - OpenedProcessId = 42, - TargetIs64BitValue = true - }; - RuntimeClient runtime = new(dispatcher, probe, static () => 84, new Version(0, 1, 0), new Version(1, 0, 0)); + FakeRuntimeObservationPort port = new(); + RuntimeClient runtime = new(dispatcher, port, static () => 84, new Version(1, 0, 0), new Version(2, 0, 0)); bool succeeded = runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, @@ -31,69 +39,265 @@ public void SnapshotReportsOnlyTheObservedCeLineAndIndependentCapabilities() Assert.True(succeeded); Assert.Equal(default, failure); Assert.Equal(84, snapshot.Epoch); - Assert.Equal(7.7d, snapshot.ObservedCheatEngineVersion); - Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.QualifiedCheatEngineBaseline); - Assert.Equal(CheatEngineArchitecture.X64, snapshot.SystemArchitecture); - Assert.Equal(CheatEngineArchitecture.X64, snapshot.TargetArchitecture); - Assert.Equal(PointerSize.Bit64, snapshot.TargetPointerSize); - Assert.Equal(TargetAbi.Windows, snapshot.TargetAbi); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, - out RuntimeCapabilityAvailability versionCapability)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Available, versionCapability.State); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, - out RuntimeCapabilityAvailability targetCapability)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Available, targetCapability.State); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.Version.CheatEngineVersion); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.Version.QualifiedCheatEngineBaseline); + Assert.True(snapshot.Version.IsOnQualifiedCheatEngineLine); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Platform.HostArchitecture); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Platform.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.Platform.TargetBitness); + Assert.Equal(TargetAbi.Windows, snapshot.Platform.TargetAbi); + Assert.Equal(8, snapshot.Platform.ConfiguredPointerSizeBytes); + Assert.Equal(CheatEngineOperatingSystem.Windows, snapshot.Platform.HostOperatingSystem); + Assert.Equal(PointerSize.Bit64, snapshot.Platform.CheatEngineBitness); + Assert.Equal(TargetBackend.LocalProcess, snapshot.Platform.TargetBackend); + Assert.False(snapshot.Platform.TargetIsAndroid); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, ProcessSelectionHost(snapshot).State); Assert.Equal(1, dispatcher.InvocationCount); + // The SDK snapshot answers alone: no host or target fact is read again. + Assert.Equal([nameof(IRuntimeObservationPort.TryObserveRuntimeInfo)], port.Calls); + Assert.Empty(port.TargetCalls); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void SnapshotWithoutASelectedTargetLeavesEveryTargetFactUnknown() + { + // Spike C3 D2: with no target opened Cheat Engine reports x64-like facts, so the SDK reads none of them. + FakeRuntimeObservationPort port = new() + { + TargetStatus = ProcessOperationStatus.TargetNotAttached + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Platform.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, snapshot.Platform.TargetBitness); + Assert.Equal(TargetAbi.Unknown, snapshot.Platform.TargetAbi); + Assert.Null(snapshot.Platform.ConfiguredPointerSizeBytes); + Assert.Null(snapshot.Platform.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Platform.HostArchitecture); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, ProcessSelectionHost(snapshot).State); + Assert.Empty(port.TargetCalls); + } + + [Fact] + public void SnapshotReportsAnAbsentTargetGlobalAsAMissingProcessSelectionHostGate() + { + FakeRuntimeObservationPort port = new() + { + TargetStatus = ProcessOperationStatus.GlobalUnavailable + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.ProcessSelection, + out ClientCapabilityAvailability processSelection)); + Assert.Equal(ClientCapabilityEvidenceState.Missing, processSelection.Evidence.Host.State); + Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, processSelection.State); + Assert.Contains("Process.Current", processSelection.Evidence.Host.Reason, StringComparison.Ordinal); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Platform.TargetArchitecture); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(ProcessOperationStatusKind.FileAsProcessTarget)] + [InlineData(ProcessOperationStatusKind.TargetChanged)] + public void SnapshotWithoutAnSdkSnapshotStillReportsTheHostFactsAndNoTargetFact(ProcessOperationStatusKind kind) + { + // The SDK produces no RuntimeInfo for a file opened as a process or a target change; the host facts are read + // on their own and no target fact is attributed. + FakeRuntimeObservationPort port = new() + { + TargetStatus = TargetObservations.Status(kind) + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + bool succeeded = runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.Version.CheatEngineVersion); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Platform.HostArchitecture); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Platform.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, snapshot.Platform.TargetBitness); + Assert.Equal(kind == ProcessOperationStatusKind.FileAsProcessTarget + ? TargetBackend.FileAsProcess + : TargetBackend.Unknown, snapshot.Platform.TargetBackend); + ClientCapabilityEvidenceGate host = ProcessSelectionHost(snapshot); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, host.State); + Assert.Contains(kind.ToString(), host.Reason, StringComparison.Ordinal); + Assert.Equal( + [nameof(IRuntimeObservationPort.TryObserveRuntimeInfo), nameof(IRuntimeObservationPort.ObserveHost)], + port.Calls); } [Fact] - public void SnapshotMarksOnlyTheUnavailableProbeUnavailableWithoutInventingItsValue() + [Trait("Qualification", "Q32")] + public void SnapshotOfACeServerTargetKeepsTheFactsCheatEngineReportsAboutIt() { - FakeRuntimeProbe probe = new() + FakeRuntimeObservationPort port = new() { - ReportedVersion = 7.7d, - SystemArchitectureCode = 1, - TargetAbiCode = 0, - OpenedProcessId = 42, - TargetIs64BitException = new EngineGlobalUnavailableException("Runtime.TargetArchitecture") + Target = TargetObservations.Create(processId: 900, backend: TargetBackend.CEServer, isAndroid: true, + abiCode: 1, isX86Family: false, isArmFamily: true) }; - RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 1); + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); - Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, - out RuntimeCapabilityAvailability targetCapability)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, targetCapability.State); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.SystemArchitecture, - out RuntimeCapabilityAvailability systemCapability)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Available, systemCapability.State); + Assert.Equal(CheatEngineArchitecture.Arm64, snapshot.Platform.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.Platform.TargetBitness); + Assert.Equal(TargetAbi.Unix, snapshot.Platform.TargetAbi); } [Fact] - public void SnapshotLeavesTargetArchitectureUnknownWhenNoTargetIsSelectedWithoutCallingTargetProbe() + public void SnapshotReadsEachHostFactAloneWhenTheHostObservationFails() + { + // One raising global fails the SDK's aggregate host read; the others stay known when read one by one. + FakeRuntimeObservationPort port = new() + { + HostStatus = LuaOperationStatus.LuaFailure(LuaStatus.RuntimeError), + SystemArchitectureStatus = LuaOperationStatus.InvalidResult, + FileVersionStatus = LuaOperationStatus.NilResult + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Null(snapshot.Version.CheatEngineVersion); + Assert.False(snapshot.Version.IsOnQualifiedCheatEngineLine); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Platform.HostArchitecture); + // The target is observed on its own through the target observation policy. + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Platform.TargetArchitecture); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, ProcessSelectionHost(snapshot).State); + Assert.Equal( + [ + nameof(IRuntimeObservationPort.TryObserveRuntimeInfo), nameof(IRuntimeObservationPort.ObserveHost), + nameof(IRuntimeObservationPort.TryGetCheatEngineFileVersion), + nameof(IRuntimeObservationPort.TryGetSystemArchitecture), + nameof(IRuntimeObservationPort.TryIsCheatEngine64Bit), nameof(IRuntimeObservationPort.TryGetOperatingSystem) + ], port.Calls); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void SnapshotKeepsTheNarrowedTargetFactsWhenOneTargetFactRaises() + { + FakeRuntimeObservationPort port = new() + { + TargetStatus = TargetObservations.LuaFailure, + Target = TargetObservations.Create(configuredPointerSizeBytes: 4) + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.Platform.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.Platform.TargetBitness); + Assert.Equal(TargetAbi.Unknown, snapshot.Platform.TargetAbi); + Assert.Equal(4, snapshot.Platform.ConfiguredPointerSizeBytes); + Assert.True(snapshot.Platform.ConfiguredPointerSizeDiffersFromBitness); + } + + [Theory] + [InlineData(ProcessOperationStatusKind.GlobalUnavailable, ClientCapabilityEvidenceState.Missing, + ClientCapabilityAvailabilityState.Unavailable)] + [InlineData(ProcessOperationStatusKind.ProtectedLuaFailure, ClientCapabilityEvidenceState.Faulted, + ClientCapabilityAvailabilityState.Unknown)] + [InlineData(ProcessOperationStatusKind.InvalidResult, ClientCapabilityEvidenceState.Malformed, + ClientCapabilityAvailabilityState.Unknown)] + public void SnapshotSeparatesMissingFaultedAndMalformedProcessSelectionEvidence(ProcessOperationStatusKind kind, + ClientCapabilityEvidenceState expectedHost, ClientCapabilityAvailabilityState expectedAvailability) { - FakeRuntimeProbe probe = new() + // Without an SDK snapshot the host gate comes from the status of the selected-process observation. + FakeRuntimeObservationPort port = new() { - ReportedVersion = 7.7d, SystemArchitectureCode = 1, TargetAbiCode = 0, OpenedProcessId = 0 + RuntimeInfoStatus = TargetObservations.LuaFailure, + TargetStatus = TargetObservations.Status(kind), + CurrentReads = [(TargetObservations.Status(kind), 0)] }; - RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 1); + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 7); CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, - out RuntimeCapabilityAvailability targetCapability)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, targetCapability.State); - Assert.Equal(0, probe.TargetIs64BitCallCount); + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.ProcessSelection, + out ClientCapabilityAvailability availability)); + Assert.Equal(expectedHost, availability.Evidence.Host.State); + Assert.Equal(expectedAvailability, availability.State); } [Fact] - public void TryGetSnapshotObservesCancellationBeforeDispatchingAProbe() + [Trait("Qualification", "Q31")] + public void SnapshotKeepsAConfiguredPointerSizeOfFourSeparateFromAnX64Target() + { + // Spike C3 D3: setPointerSize(4) on an x64 target leaves targetIs64Bit true and readPointer 8 bytes wide. + FakeRuntimeObservationPort port = new() + { + Target = TargetObservations.Create(configuredPointerSizeBytes: 4) + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.X64, snapshot.Platform.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.Platform.TargetBitness); + Assert.Equal(PointerSize.Bit32, snapshot.Platform.ConfiguredPointerSize); + Assert.Equal(4, snapshot.Platform.ConfiguredPointerSizeBytes); + Assert.True(snapshot.Platform.ConfiguredPointerSizeDiffersFromBitness); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void SnapshotKeepsARawConfiguredPointerSizeOutsideFourAndEight() + { + // Spike C3 D3(b): setPointerSize accepts any integer; 2 was stored and read back. + FakeRuntimeObservationPort port = new() + { + Target = TargetObservations.Create(configuredPointerSizeBytes: 2) + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(2, snapshot.Platform.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Unknown, snapshot.Platform.ConfiguredPointerSize); + Assert.Equal(PointerSize.Bit64, snapshot.Platform.TargetBitness); + Assert.True(snapshot.Platform.ConfiguredPointerSizeDiffersFromBitness); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(true, false, true, CheatEngineArchitecture.X64)] + [InlineData(true, false, false, CheatEngineArchitecture.X86)] + [InlineData(false, true, true, CheatEngineArchitecture.Arm64)] + [InlineData(false, true, false, CheatEngineArchitecture.Arm32)] + [InlineData(true, true, true, CheatEngineArchitecture.Unknown)] + [InlineData(false, false, false, CheatEngineArchitecture.Unknown)] + [InlineData(null, null, true, CheatEngineArchitecture.Unknown)] + [InlineData(true, null, false, CheatEngineArchitecture.Unknown)] + public void SnapshotReportsTheSdkIsaDerivationAndNeverOneFromTheBitnessAlone(bool? isX86, bool? isArm, + bool is64Bit, CheatEngineArchitecture expected) + { + FakeRuntimeObservationPort port = new() + { + Target = TargetObservations.Create(is64Bit: is64Bit, isX86Family: isX86, isArmFamily: isArm) + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(expected, snapshot.Platform.TargetArchitecture); + Assert.Equal(is64Bit ? PointerSize.Bit64 : PointerSize.Bit32, snapshot.Platform.TargetBitness); + } + + [Fact] + public void TryGetSnapshotObservesCancellationBeforeDispatchingAnObservation() { InlineDispatcher dispatcher = new(); - RuntimeClient runtime = new(dispatcher, new FakeRuntimeProbe(), static () => 1); + FakeRuntimeObservationPort port = new(); + RuntimeClient runtime = new(dispatcher, port, static () => 1); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); @@ -104,74 +308,163 @@ public void TryGetSnapshotObservesCancellationBeforeDispatchingAProbe() Assert.Equal(default, snapshot); Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); Assert.Equal(0, dispatcher.InvocationCount); + Assert.Empty(port.Calls); } [Fact] - public void TryGetSdkCapabilityReturnsUnknownForAnIdentifierThatWasNotProbed() + [Trait("Qualification", "Q32")] + public void SnapshotReportsTheBackendOfEveryTargetItObserves() { - RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 1); - RuntimeCapabilityId customCapability = new("Client.Custom"); + // SDK2-08[O]: a CEServer target keeps the facts Cheat Engine reports about it; a file opened as a process is a + // snapshot too, with its backend and no target fact. + FakeRuntimeObservationPort remote = new() + { + Target = TargetObservations.Create(processId: 900, backend: TargetBackend.CEServer) + }; + FakeRuntimeObservationPort file = new() + { + TargetStatus = ProcessOperationStatus.FileAsProcessTarget + }; - bool succeeded = runtime.TryGetSdkCapability( - customCapability, - out RuntimeCapabilityAvailability availability, - out CheatEngineFailure failure, - TestContext.Current.CancellationToken); + CheatEngineRuntimeSnapshot remoteSnapshot = new RuntimeClient(new InlineDispatcher(), remote, static () => 1) + .GetSnapshot(TestContext.Current.CancellationToken); + CheatEngineRuntimeSnapshot fileSnapshot = new RuntimeClient(new InlineDispatcher(), file, static () => 1) + .GetSnapshot(TestContext.Current.CancellationToken); - Assert.True(succeeded); - Assert.Equal(default, failure); - Assert.Equal(customCapability, availability.Capability); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, availability.State); + Assert.Equal(TargetBackend.CEServer, remoteSnapshot.Platform.TargetBackend); + Assert.Equal(CheatEngineArchitecture.X64, remoteSnapshot.Platform.TargetArchitecture); + Assert.Equal(TargetBackend.FileAsProcess, fileSnapshot.Platform.TargetBackend); + Assert.Equal(PointerSize.Unknown, fileSnapshot.Platform.TargetBitness); + Assert.Equal(CheatEngineOperatingSystem.Windows, fileSnapshot.Platform.HostOperatingSystem); + } + + [Theory] + [InlineData(SdkVersion + "+" + SdkCommit, true)] + [InlineData("2.0.1", false)] + [InlineData(null, false)] + public void SnapshotReportsTheLoadedSdkPackageAndWhetherItIsTheReviewedOne(string? loaded, bool reviewed) + { + ConsumedSdkIdentity identity = new(SdkVersion, SdkCommit, SdkContentHash, SdkSupportedMajor, loaded); + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), static () => 1, + sdkIdentity: identity); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(loaded, snapshot.Version.SdkPackageVersion); + Assert.Equal(reviewed, snapshot.Version.IsReviewedSdkPackage); + } + + [Fact] + public void SnapshotComparesTheCheatEngineVersionLineAsIntegers() + { + // 7.10 is its own line: the coarse decimal of getCEVersion would have read it as 7.1. + FakeRuntimeObservationPort port = new() + { + Host = FakeRuntimeObservationPort.DefaultHost with + { + FileVersion = new CheatEngineVersion(7, 10, 0, 1) + } + }; + RuntimeClient runtime = new(new InlineDispatcher(), port, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(new CheatEngineVersion(7, 10, 0, 1), snapshot.Version.CheatEngineVersion); + Assert.False(snapshot.Version.IsOnQualifiedCheatEngineLine); + } + + [Fact] + public void SnapshotReturnsADetachedRuntimeFaultAsAFailureInsteadOfThrowing() + { + // A detached SDK runtime refuses the Lua admission with InvalidOperationException; the SdkBoundary rule keeps it + // from crossing the Try method (F15). + InvalidOperationException detached = new("The plugin is not enabled."); + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort { Fault = detached }, + static () => 1); + + bool succeeded = runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal("Runtime.GetSnapshot", failure.Operation); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Same(detached, failure.Exception); } [Fact] public void SnapshotReportsClientGatesWithoutClaimingUnprobedDomainsAreAvailable() { - RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 1); + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), static () => 1); CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.ValueScanning, + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.ValueScanning, out ClientCapabilityAvailability valueScanning)); - Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, valueScanning.State); - Assert.Equal(ClientCapabilityEvidenceState.Missing, valueScanning.Evidence.Implementation.State); - Assert.Equal(ClientCapabilityEvidenceState.Missing, valueScanning.Evidence.Package.State); - Assert.Contains("unavailable adapter", valueScanning.Reason, StringComparison.OrdinalIgnoreCase); - Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.TypedMemory, + // An experimental operational adapter: implemented, but its qualification gate stays unknown (Q25, Q26). + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, valueScanning.State); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, valueScanning.Evidence.Implementation.State); + Assert.Equal(RuntimeClient.ExperimentalImplementationReason("CECLIENT5001"), + valueScanning.Evidence.Implementation.Reason); + // Without an embedded identity the package gate is the evidence's Unknown, as for every other capability. + Assert.Equal(ClientCapabilityEvidenceState.Unknown, valueScanning.Evidence.Package.State); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, valueScanning.Evidence.LiveQualification.State); + // The allocations are experimental too: implemented, with a qualification gate unknown until Q30.a. + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.Allocations, + out ClientCapabilityAvailability allocations)); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, allocations.State); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, allocations.Evidence.Implementation.State); + Assert.Equal(RuntimeClient.ExperimentalImplementationReason("CECLIENT5002"), + allocations.Evidence.Implementation.Reason); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, allocations.Evidence.LiveQualification.State); + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.TypedMemory, out ClientCapabilityAvailability typedMemory)); Assert.Equal(ClientCapabilityAvailabilityState.Unknown, typedMemory.State); - Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, out ClientCapabilityAvailability unsafeLua)); Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, unsafeLua.State); - ClientCapabilityId[] unavailableCapabilities = - [ - ClientCapabilityId.Allocations, - ClientCapabilityId.RemoteExecution, - ClientCapabilityId.Debugger, - ClientCapabilityId.Hotkeys, - ClientCapabilityId.Timers, - ClientCapabilityId.Dbvm - ]; - foreach (ClientCapabilityId capability in unavailableCapabilities) + // Instruction assembly is experimental too: implemented, with a qualification gate unknown until Q32. + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.Assembly, + out ClientCapabilityAvailability assembly)); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, assembly.State); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, assembly.Evidence.Implementation.State); + Assert.Equal(RuntimeClient.ExperimentalImplementationReason("CECLIENT5003"), + assembly.Evidence.Implementation.Reason); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, assembly.Evidence.LiveQualification.State); + // Every capability composes an operational adapter: every implementation gate is satisfied, and none is + // available yet. + Assert.All(snapshot.Capabilities.Entries.ToArray(), static availability => { - Assert.True(snapshot.ClientCapabilities.TryGet(capability, out ClientCapabilityAvailability availability)); - Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, availability.State); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, availability.Evidence.Implementation.State); Assert.False(availability.IsAvailable); - } + }); + } - ClientCapabilityId[] additionallyUnavailableCapabilities = - [ - ClientCapabilityId.Assembly, - ClientCapabilityId.Speed, - ClientCapabilityId.Hashing - ]; - foreach (ClientCapabilityId capability in additionallyUnavailableCapabilities) + /// + /// The snapshot reports the catalog's capabilities in catalog order, each with a satisfied implementation gate and + /// the host gate of its row (the catalog is the capability set the other facts of this class use). + /// + [Fact] + public void SnapshotComposesEveryCapabilityFromItsCatalogRow() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), + static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(ClientCapabilityCatalog.Entries.Select(static entry => entry.Id), + snapshot.Capabilities.Entries.ToArray().Select(static availability => availability.Capability)); + foreach (ClientCapabilityDescriptor entry in ClientCapabilityCatalog.Entries) { - Assert.True(snapshot.ClientCapabilities.TryGet(capability, out ClientCapabilityAvailability availability)); - Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, availability.State); - Assert.Equal(ClientCapabilityEvidenceState.Missing, availability.Evidence.Implementation.State); + Assert.True(snapshot.Capabilities.TryGet(entry.Id, out ClientCapabilityAvailability availability)); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, availability.Evidence.Implementation.State); + Assert.Equal(entry.Host == CapabilityHostSource.SdkSelectedProcess + ? ClientCapabilityEvidenceState.Satisfied + : ClientCapabilityEvidenceState.Unknown, availability.Evidence.Host.State); } + + Assert.Equal(OperationalCapabilities, ClientCapabilityCatalog.Entries.Select(static entry => entry.Id)); } [Fact] @@ -179,7 +472,7 @@ public void SnapshotReportsUnsafeLuaPolicyWithoutTreatingItAsHostEvidence() { RuntimeClient runtime = new( new InlineDispatcher(), - new FakeRuntimeProbe(), + new FakeRuntimeObservationPort(), static () => 1, policy: new CoreClientPolicy([], true)); @@ -198,48 +491,47 @@ public void SnapshotReportsUnsafeLuaPolicyWithoutTreatingItAsHostEvidence() Assert.False(availability.IsAvailable); } - [Fact] - public void SnapshotClassifiesAnInvalidVersionAsMalformedWithoutCallingItUnavailable() + [Theory] + [InlineData(false, ClientCapabilityEvidenceState.Missing, ClientCapabilityAvailabilityState.Unavailable)] + [InlineData(true, ClientCapabilityEvidenceState.Satisfied, ClientCapabilityAvailabilityState.Unknown)] + [Trait("Qualification", "Q44")] + public void SnapshotReportsTheAutoAssemblerPatchesPolicyGateFromTheActivationOptIn(bool enabled, + ClientCapabilityEvidenceState policy, ClientCapabilityAvailabilityState state) { RuntimeClient runtime = new( new InlineDispatcher(), - new FakeRuntimeProbe { ReportedVersion = double.NaN }, - static () => 1); + new FakeRuntimeObservationPort(), + static () => 1, + policy: new CoreClientPolicy([], false, enableAutoAssemblerPatches: enabled)); CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - Assert.Null(snapshot.ObservedCheatEngineVersion); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, - out RuntimeCapabilityAvailability availability)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, availability.State); - } - - [Fact] - public void SnapshotSeparatesMissingFaultedAndMalformedOpenedProcessEvidence() - { - AssertOpenedProcessEvidence( - new FakeRuntimeProbe { OpenedProcessException = new EngineGlobalUnavailableException("Runtime.Process") }, - ClientCapabilityEvidenceState.Missing, - ClientCapabilityAvailabilityState.Unavailable); - AssertOpenedProcessEvidence( - new FakeRuntimeProbe { OpenedProcessException = new EngineOperationFailedException("Runtime.Process") }, - ClientCapabilityEvidenceState.Faulted, - ClientCapabilityAvailabilityState.Unknown); - AssertOpenedProcessEvidence( - new FakeRuntimeProbe { OpenedProcessId = -1 }, - ClientCapabilityEvidenceState.Malformed, - ClientCapabilityAvailabilityState.Unknown); + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.AutoAssemblerPatches, + out ClientCapabilityAvailability patches)); + Assert.Equal(policy, patches.Evidence.Policy.State); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, patches.Evidence.Implementation.State); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, patches.Evidence.Host.State); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, patches.Evidence.LiveQualification.State); + Assert.Equal(state, patches.State); + Assert.False(patches.IsAvailable); + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, + out ClientCapabilityAvailability unsafeLua)); + Assert.Equal(ClientCapabilityEvidenceState.Missing, unsafeLua.Evidence.Policy.State); + if (!enabled) + { + Assert.Contains("EnableAutoAssemblerPatches", patches.Evidence.Policy.Reason, StringComparison.Ordinal); + } } [Fact] public void SnapshotReportsAnInactiveActivationAsALifetimeGate() { - RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 7, + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), static () => 7, isActivationCurrent: static () => false); CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.TypedMemory, + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.TypedMemory, out ClientCapabilityAvailability availability)); Assert.Equal(ClientCapabilityEvidenceState.Missing, availability.Evidence.Lifetime.State); Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, availability.State); @@ -249,12 +541,12 @@ public void SnapshotReportsAnInactiveActivationAsALifetimeGate() [Fact] public void CapabilityQueriesReturnTheObservedSnapshotEntryOrTheDocumentedUnknownFallback() { - RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 7); + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), static () => 7); - bool sdkSucceeded = runtime.TryGetSdkCapability( - RuntimeCapabilityId.CheatEngineVersion, - out RuntimeCapabilityAvailability sdkAvailability, - out CheatEngineFailure sdkFailure, + bool undefinedSucceeded = runtime.TryGetClientCapability( + new ClientCapabilityId("Client.Custom"), + out ClientCapabilityAvailability undefined, + out CheatEngineFailure undefinedFailure, TestContext.Current.CancellationToken); bool clientSucceeded = runtime.TryGetClientCapability( ClientCapabilityId.ProcessSelection, @@ -262,9 +554,10 @@ public void CapabilityQueriesReturnTheObservedSnapshotEntryOrTheDocumentedUnknow out CheatEngineFailure clientFailure, TestContext.Current.CancellationToken); - Assert.True(sdkSucceeded); - Assert.Equal(default, sdkFailure); - Assert.Equal(RuntimeCapabilityAvailabilityState.Available, sdkAvailability.State); + Assert.True(undefinedSucceeded); + Assert.Equal(default, undefinedFailure); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, undefined.State); + Assert.Contains("does not define a probe or policy gate", undefined.Reason, StringComparison.Ordinal); Assert.True(clientSucceeded); Assert.Equal(default, clientFailure); Assert.Equal(ClientCapabilityAvailabilityState.Unknown, clientAvailability.State); @@ -276,177 +569,257 @@ public void CapabilityQueriesReturnTheObservedSnapshotEntryOrTheDocumentedUnknow public void CapabilityQueriesRejectDefaultIdentifiersBeforeDispatching() { InlineDispatcher dispatcher = new(); - RuntimeClient runtime = new(dispatcher, new FakeRuntimeProbe(), static () => 7); + RuntimeClient runtime = new(dispatcher, new FakeRuntimeObservationPort(), static () => 7); - Assert.Throws(() => runtime.TryGetSdkCapability( - default, out _, out _, TestContext.Current.CancellationToken)); Assert.Throws(() => runtime.TryGetClientCapability( default, out _, out _, TestContext.Current.CancellationToken)); Assert.Equal(0, dispatcher.InvocationCount); } - [Fact] - public void SnapshotMarksUnavailableAndDetachedHostProbesWithoutInventingRuntimeFacts() + [Theory] + [InlineData(SdkVersion + "+" + SdkCommit, ClientCapabilityEvidenceState.Satisfied, true)] + [InlineData("2.0.1", ClientCapabilityEvidenceState.Satisfied, false)] + [InlineData("2.1.0-beta.1", ClientCapabilityEvidenceState.Satisfied, false)] + [InlineData("2.0.0-rc.1", ClientCapabilityEvidenceState.Missing, false)] + [InlineData("1.0.0", ClientCapabilityEvidenceState.Missing, false)] + [InlineData("3.0.0", ClientCapabilityEvidenceState.Missing, false)] + [InlineData(OtherSdkInformationalVersion, ClientCapabilityEvidenceState.Missing, false)] + [InlineData(null, ClientCapabilityEvidenceState.Unknown, false)] + public void PackageGateAcceptsEveryReleaseOfTheSupportedMajorAtOrAboveThePin(string? loaded, + ClientCapabilityEvidenceState expected, bool exact) { - RuntimeClient runtime = new( - new InlineDispatcher(), - new FakeRuntimeProbe - { - VersionException = new EngineGlobalUnavailableException("Runtime.Version"), - SystemArchitectureException = - new EngineCapabilityUnavailableException("Runtime.SystemArchitecture"), - TargetAbiException = new EngineGlobalUnavailableException("Runtime.TargetAbi"), - OpenedProcessException = new EngineCapabilityUnavailableException("Runtime.OpenedProcess") - }, - static () => 7); - - CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - - Assert.Null(snapshot.ObservedCheatEngineVersion); - Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.SystemArchitecture); - Assert.Equal(TargetAbi.Unknown, snapshot.TargetAbi); - Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, - out RuntimeCapabilityAvailability version)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, version.State); - Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.SystemArchitecture, - out RuntimeCapabilityAvailability system)); - Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, system.State); - } - - private static void AssertOpenedProcessEvidence( - FakeRuntimeProbe probe, - ClientCapabilityEvidenceState expectedHostState, - ClientCapabilityAvailabilityState expectedAvailabilityState) - { - RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 7); - - CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); - - Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.ProcessSelection, - out ClientCapabilityAvailability availability)); - Assert.Equal(expectedHostState, availability.Evidence.Host.State); - Assert.Equal(expectedAvailabilityState, availability.State); - } - - private sealed class FakeRuntimeProbe : IRuntimeProbe - { - internal double ReportedVersion - { - get; - init; - } = 7.7d; - - internal int SystemArchitectureCode + // The gate follows the declared dependency range: same major as the pin and at least the pin by SemVer + // precedence (a prerelease of the pin is below it); the reason says whether it is the reviewed package itself. + ConsumedSdkIdentity identity = new(SdkVersion, SdkCommit, SdkContentHash, SdkSupportedMajor, loaded); + + ClientCapabilityAvailability typedMemory = GetClientCapability(identity, ClientCapabilityId.TypedMemory); + string reason = typedMemory.Evidence.Package.Reason; + + Assert.Equal(expected, typedMemory.Evidence.Package.State); + Assert.Equal(exact, identity.ExactReviewedIdentity); + Assert.Equal(expected == ClientCapabilityEvidenceState.Missing + ? ClientCapabilityAvailabilityState.Unavailable + : ClientCapabilityAvailabilityState.Unknown, typedMemory.State); + Assert.DoesNotContain("refuse", reason, StringComparison.OrdinalIgnoreCase); + if (loaded is not null) { - get; - init; - } = 1; - - internal int TargetAbiCode - { - get; - init; + Assert.Contains(loaded, reason, StringComparison.Ordinal); } - internal long OpenedProcessId + if (exact) { - get; - init; + Assert.Contains("exactly the reviewed package", reason, StringComparison.Ordinal); + Assert.Contains(SdkContentHash, reason, StringComparison.Ordinal); } - - internal bool TargetIs64BitValue + else if (expected == ClientCapabilityEvidenceState.Satisfied) { - get; - init; + Assert.Contains("is a release of the supported CheatEngine.SDK 2.x at or above 2.0.0", reason, + StringComparison.Ordinal); + Assert.Contains($"another release than the reviewed package this Client build consumed ({SdkVersion}+{SdkCommit})", + reason, StringComparison.Ordinal); } - - internal Exception? TargetIs64BitException + else if (expected == ClientCapabilityEvidenceState.Missing) { - get; - init; + Assert.Contains("is not a release of the supported CheatEngine.SDK 2.x at or above 2.0.0", reason, + StringComparison.Ordinal); } - - internal Exception? VersionException + else { - get; - init; + Assert.Contains("declares no informational version", reason, StringComparison.Ordinal); } - internal Exception? SystemArchitectureException + foreach (ClientCapabilityId operational in OperationalCapabilities) { - get; - init; + Assert.Equal(identity.PackageGate, GetClientCapability(identity, operational).Evidence.Package); } + } - internal Exception? TargetAbiException - { - get; - init; - } + [Fact] + public void PackageGateIsUnknownWithoutEmbeddedIdentity() + { + ConsumedSdkIdentity identity = new(null, null, null, null, $"{SdkVersion}+{SdkCommit}"); - internal Exception? OpenedProcessException - { - get; - init; - } + ClientCapabilityAvailability typedMemory = GetClientCapability(identity, ClientCapabilityId.TypedMemory); + + Assert.False(identity.IsEmbedded); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, typedMemory.Evidence.Package.State); + Assert.Contains("embeds no consumed CheatEngine.SDK identity", typedMemory.Evidence.Package.Reason, + StringComparison.Ordinal); + } - internal int TargetIs64BitCallCount + [Fact] + public void TheIdentityOfThisBuildMatchesTheLoadedSdkPackage() + { + // The Core assembly under test embeds the identity of its locked and restored CheatEngine.SDK package (a + // build that cannot embed it fails with CHEATENGINECLIENT9050), and the test process loads that package, so + // the production identity is embedded and Satisfied. + ConsumedSdkIdentity current = ConsumedSdkIdentity.Current; + + Assert.True(current.IsEmbedded, "The Core assembly under test embeds no consumed CheatEngine.SDK identity."); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, current.PackageGate.State); + Assert.True(current.ExactReviewedIdentity); + Assert.Equal("the reviewed package", current.IdentityLabel); + Assert.Equal(typeof(RuntimeInfo).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .Cast().Single().InformationalVersion, + current.LoadedInformationalVersion); + } + + [Fact] + [Trait("Qualification", "Q44")] + public void NoCapabilityIsAvailableWithoutEverySixGates() + { + // Even with a matching package, a selected target and an enabled unsafe-Lua policy, the qualification gate stays + // unknown and the unprobed host gates stay unknown: no capability is announced as available (ADR-09). + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), + static () => 1, policy: new CoreClientPolicy([], true), sdkIdentity: MatchingIdentity()); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + int declaredCapabilities = typeof(ClientCapabilityId) + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Count(static property => property.PropertyType == typeof(ClientCapabilityId)); + Assert.Equal(declaredCapabilities, snapshot.Capabilities.Count); + foreach (ClientCapabilityAvailability capability in snapshot.Capabilities.Entries) { - get; - private set; + Assert.False(capability.IsAvailable, capability.Capability.Value); + Assert.NotEqual(ClientCapabilityAvailabilityState.Available, capability.State); + Assert.NotEqual(ClientCapabilityAvailabilityState.Available, capability.Evidence.AvailabilityState); } + } - public double GetCheatEngineVersion() - { - if (VersionException is not null) - { - throw VersionException; - } + [Fact] + [Trait("Qualification", "Q44")] + public void QualificationGateStaysUnknownWithoutACommittedClientReceipt() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), + static () => 1, sdkIdentity: MatchingIdentity()); - return ReportedVersion; - } + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + string reason = RuntimeClient.QualificationUnknownReason(MatchingIdentity()); - public int GetSystemArchitecture() + foreach (ClientCapabilityAvailability capability in snapshot.Capabilities.Entries) { - if (SystemArchitectureException is not null) - { - throw SystemArchitectureException; - } - - return SystemArchitectureCode; + Assert.Equal(ClientCapabilityEvidenceState.Unknown, capability.Evidence.LiveQualification.State); + // Arbitrary Lua has no live scenario: its gate says it is never qualified, whatever the evidence. + Assert.Equal(capability.Capability == ClientCapabilityId.UnsafeLuaExecution + ? "Client.UnsafeLuaExecution is never host-qualified: no live scenario covers it." + : reason, + capability.Evidence.LiveQualification.Reason); } - public int GetTargetAbi() - { - if (TargetAbiException is not null) - { - throw TargetAbiException; - } + Assert.Contains("SDK-branch receipts never qualify the Client tuple", reason, StringComparison.Ordinal); + Assert.Contains(ConsumedSdkIdentity.SupportedHostProfileId, reason, StringComparison.Ordinal); + // The Client tuple names the embedded identity, not a version written in the source. + Assert.Contains($"CheatEngine.SDK {SdkVersion}+{SdkCommit}", reason, StringComparison.Ordinal); + } - return TargetAbiCode; - } + [Fact] + public void QualificationGateFollowsTheEmbeddedHostEvidence() + { + HostQualificationRecord evidence = new("20260930T101530Z-a1b2", "1.0.0", $"{SdkVersion}+{SdkCommit}", + ConsumedSdkIdentity.SupportedHostProfileId, CheatEngineVersion.Ce77010621, + [ + new HostQualifiedCapability(ClientCapabilityId.TypedMemory, ["Q20", "Q21", "Q33"], [], [CheatEngineArchitecture.X64]) + ]); + + CheatEngineRuntimeSnapshot qualified = new RuntimeClient(new InlineDispatcher(), new FakeRuntimeObservationPort(), + static () => 1, sdkIdentity: MatchingIdentity(), qualificationEvidence: evidence, clientVersion: "1.0.0") + .GetSnapshot(TestContext.Current.CancellationToken); + CheatEngineRuntimeSnapshot otherClient = new RuntimeClient(new InlineDispatcher(), new FakeRuntimeObservationPort(), + static () => 1, sdkIdentity: MatchingIdentity(), qualificationEvidence: evidence, clientVersion: "1.0.1") + .GetSnapshot(TestContext.Current.CancellationToken); + + Assert.True(qualified.Capabilities.TryGet(ClientCapabilityId.TypedMemory, out ClientCapabilityAvailability memory)); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, memory.Evidence.LiveQualification.State); + Assert.True(qualified.Capabilities.TryGet(ClientCapabilityId.Tables, out ClientCapabilityAvailability tables)); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, tables.Evidence.LiveQualification.State); + Assert.True(otherClient.Capabilities.TryGet(ClientCapabilityId.TypedMemory, out ClientCapabilityAvailability stale)); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, stale.Evidence.LiveQualification.State); + Assert.Contains("1.0.1", stale.Evidence.LiveQualification.Reason, StringComparison.Ordinal); + } - public long GetOpenedProcessId() - { - if (OpenedProcessException is not null) - { - throw OpenedProcessException; - } + [Fact] + [Trait("Qualification", "Q44")] + public void QualificationReasonNamesNoSdkVersionWithoutAnEmbeddedIdentity() + { + string reason = RuntimeClient.QualificationUnknownReason(ConsumedSdkIdentity.NotEmbedded); - return OpenedProcessId; - } + Assert.Contains("embeds no identity", reason, StringComparison.Ordinal); + Assert.Contains(ConsumedSdkIdentity.SupportedHostProfileId, reason, StringComparison.Ordinal); + Assert.DoesNotMatch(@"CheatEngine\.SDK \d", reason); + } + + [Fact] + [Trait("Qualification", "Q45")] + public void TheRuntimeObservationPortExposesOnlyObservationMembers() + { + // Q45 / ADR-09a: a runtime observation observes; it never selects a process, loads a table or changes a host + // setting. Every member returns the SDK's status or copied observation and fills only out parameters, except the + // incarnation that ValidateSelection compares. + string[] allowed = + [ + nameof(IRuntimeObservationPort.TryObserveRuntimeInfo), nameof(IRuntimeObservationPort.ObserveHost), + nameof(IRuntimeObservationPort.TryGetCheatEngineFileVersion), + nameof(IRuntimeObservationPort.TryGetSystemArchitecture), nameof(IRuntimeObservationPort.TryIsCheatEngine64Bit), + nameof(IRuntimeObservationPort.TryGetOperatingSystem), nameof(IRuntimeObservationPort.ObserveSelection), + nameof(IRuntimeObservationPort.ValidateSelection), nameof(ITargetObservationPort.ObserveCurrent), + nameof(ITargetObservationPort.ObserveTargetArchitecture), + nameof(ITargetObservationPort.TryGetConfiguredPointerSize) + ]; + System.Reflection.MethodInfo[] members = + [ + .. typeof(IRuntimeObservationPort).GetMethods().Where(static method => !method.IsSpecialName), + .. typeof(IRuntimeObservationPort).GetInterfaces().SelectMany(static inherited => inherited.GetMethods()) + ]; - public bool TargetIs64Bit() + Assert.Equal(allowed.Order(StringComparer.Ordinal), + members.Select(static member => member.Name).Order(StringComparer.Ordinal)); + Assert.All(members, static member => { - TargetIs64BitCallCount++; - if (TargetIs64BitException is not null) - { - throw TargetIs64BitException; - } + Assert.Contains(member.ReturnType, + (Type[]) + [ + typeof(ProcessOperationStatus), typeof(LuaOperationStatus), typeof(TargetSelectionFacts), + typeof(TargetIdentityFacts) + ]); + Assert.All(member.GetParameters(), static parameter => + Assert.True(parameter.IsOut || parameter.ParameterType == typeof(TargetProcessIncarnation), + parameter.Name)); + }); + Assert.Empty(typeof(IRuntimeObservationPort).GetProperties()); + Assert.Empty(typeof(IRuntimeObservationPort).GetEvents()); + } - return TargetIs64BitValue; - } + private static ClientCapabilityId[] OperationalCapabilities => + [ + ClientCapabilityId.ProcessSelection, ClientCapabilityId.TypedMemory, ClientCapabilityId.PatternScanning, + ClientCapabilityId.ValueScanning, ClientCapabilityId.Inspection, ClientCapabilityId.Tables, + ClientCapabilityId.ProtectedLua, ClientCapabilityId.UnsafeLuaExecution, ClientCapabilityId.Allocations, + ClientCapabilityId.Assembly, ClientCapabilityId.AutoAssemblerPatches + ]; + + private static ConsumedSdkIdentity MatchingIdentity() + { + return new ConsumedSdkIdentity(SdkVersion, SdkCommit, SdkContentHash, SdkSupportedMajor, + $"{SdkVersion}+{SdkCommit}"); + } + + private static ClientCapabilityAvailability GetClientCapability(ConsumedSdkIdentity identity, + ClientCapabilityId capability) + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeObservationPort(), + static () => 1, sdkIdentity: identity); + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + Assert.True(snapshot.Capabilities.TryGet(capability, out ClientCapabilityAvailability availability)); + return availability; + } + + private static ClientCapabilityEvidenceGate ProcessSelectionHost(CheatEngineRuntimeSnapshot snapshot) + { + Assert.True(snapshot.Capabilities.TryGet(ClientCapabilityId.ProcessSelection, + out ClientCapabilityAvailability availability)); + return availability.Evidence.Host; } private sealed class InlineDispatcher : ICheatEngineDispatcher @@ -496,7 +869,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -507,7 +880,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeObservationMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeObservationMappingTests.cs new file mode 100644 index 0000000..918b529 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeObservationMappingTests.cs @@ -0,0 +1,179 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// Every status of the CheatEngine.SDK 2.0.0 runtime and process operations has a deliberate Client counterpart, and +/// a value the SDK could add later fails closed (Q48). +/// +public sealed class RuntimeObservationMappingTests +{ + private static readonly Dictionary FailureKinds = new() + { + [ProcessOperationStatusKind.Unknown] = CheatEngineFailureKind.IndeterminateHostResult, + [ProcessOperationStatusKind.Success] = CheatEngineFailureKind.Unknown, + [ProcessOperationStatusKind.TargetNotAttached] = CheatEngineFailureKind.TargetNotAttached, + [ProcessOperationStatusKind.SelectionNotConfirmed] = CheatEngineFailureKind.OperationRejected, + [ProcessOperationStatusKind.GlobalUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [ProcessOperationStatusKind.ProtectedLuaFailure] = CheatEngineFailureKind.LuaError, + [ProcessOperationStatusKind.InvalidResult] = CheatEngineFailureKind.InvalidHostResult, + [ProcessOperationStatusKind.TargetChanged] = CheatEngineFailureKind.TargetChanged, + [ProcessOperationStatusKind.FileAsProcessTarget] = CheatEngineFailureKind.TargetIdentityUnavailable + }; + + private static readonly Dictionary HostEvidence = new() + { + [ProcessOperationStatusKind.Unknown] = ClientCapabilityEvidenceState.Unknown, + [ProcessOperationStatusKind.Success] = ClientCapabilityEvidenceState.Satisfied, + [ProcessOperationStatusKind.TargetNotAttached] = ClientCapabilityEvidenceState.Satisfied, + [ProcessOperationStatusKind.SelectionNotConfirmed] = ClientCapabilityEvidenceState.Unknown, + [ProcessOperationStatusKind.GlobalUnavailable] = ClientCapabilityEvidenceState.Missing, + [ProcessOperationStatusKind.ProtectedLuaFailure] = ClientCapabilityEvidenceState.Faulted, + [ProcessOperationStatusKind.InvalidResult] = ClientCapabilityEvidenceState.Malformed, + [ProcessOperationStatusKind.TargetChanged] = ClientCapabilityEvidenceState.Unknown, + [ProcessOperationStatusKind.FileAsProcessTarget] = ClientCapabilityEvidenceState.Unknown + }; + + private static readonly Dictionary FactEvidence = new() + { + [LuaOperationStatusKind.Unknown] = ClientCapabilityEvidenceState.Unknown, + [LuaOperationStatusKind.Success] = ClientCapabilityEvidenceState.Satisfied, + [LuaOperationStatusKind.GlobalUnavailable] = ClientCapabilityEvidenceState.Missing, + [LuaOperationStatusKind.LuaFailure] = ClientCapabilityEvidenceState.Faulted, + [LuaOperationStatusKind.NilResult] = ClientCapabilityEvidenceState.Unknown, + [LuaOperationStatusKind.InvalidResult] = ClientCapabilityEvidenceState.Malformed, + [LuaOperationStatusKind.StackUnavailable] = ClientCapabilityEvidenceState.Faulted, + [LuaOperationStatusKind.MissingResult] = ClientCapabilityEvidenceState.Malformed, + [LuaOperationStatusKind.ResultCapacityExceeded] = ClientCapabilityEvidenceState.Malformed + }; + + private static readonly Dictionary + AvailabilityEvidence = new() + { + [RuntimeCapabilityAvailabilityState.Unknown] = ClientCapabilityEvidenceState.Unknown, + [RuntimeCapabilityAvailabilityState.Available] = ClientCapabilityEvidenceState.Satisfied, + [RuntimeCapabilityAvailabilityState.Unavailable] = ClientCapabilityEvidenceState.Missing + }; + + private static readonly Dictionary SelectionIdentities = new() + { + [TargetSelectionObservationStatus.Unspecified] = SelectionIdentity.Unavailable, + [TargetSelectionObservationStatus.CurrentTargetQualified] = SelectionIdentity.Qualified, + [TargetSelectionObservationStatus.NoTargetSelected] = SelectionIdentity.NoTarget, + [TargetSelectionObservationStatus.CurrentTargetUnqualified] = SelectionIdentity.Unqualified, + [TargetSelectionObservationStatus.GlobalUnavailable] = SelectionIdentity.Unavailable, + [TargetSelectionObservationStatus.LuaFailure] = SelectionIdentity.Unavailable, + [TargetSelectionObservationStatus.InvalidResult] = SelectionIdentity.Unavailable, + [TargetSelectionObservationStatus.CurrentTargetRemoteBackend] = SelectionIdentity.RemoteBackend, + [TargetSelectionObservationStatus.CurrentTargetFileAsProcess] = SelectionIdentity.FileAsProcess, + [TargetSelectionObservationStatus.CurrentTargetBackendUnknown] = SelectionIdentity.BackendUnknown + }; + + private static readonly Dictionary IncarnationComparisons = new() + { + [TargetIdentityCheckKind.Unspecified] = IncarnationComparison.Unavailable, + [TargetIdentityCheckKind.Current] = IncarnationComparison.Current, + [TargetIdentityCheckKind.NoTargetSelected] = IncarnationComparison.SelectionChanged, + [TargetIdentityCheckKind.CurrentTargetUnqualified] = IncarnationComparison.Unavailable, + [TargetIdentityCheckKind.TargetChanged] = IncarnationComparison.SelectionChanged, + [TargetIdentityCheckKind.ProcessReused] = IncarnationComparison.ProcessReused, + [TargetIdentityCheckKind.GlobalUnavailable] = IncarnationComparison.Unavailable, + [TargetIdentityCheckKind.LuaFailure] = IncarnationComparison.Unavailable, + [TargetIdentityCheckKind.InvalidResult] = IncarnationComparison.Unavailable, + [TargetIdentityCheckKind.RemoteBackend] = IncarnationComparison.SelectionChanged, + [TargetIdentityCheckKind.FileAsProcess] = IncarnationComparison.SelectionChanged, + [TargetIdentityCheckKind.BackendUnknown] = IncarnationComparison.Unavailable + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryProcessOperationStatusMapsToItsFailureKindAndHostEvidence() + { + MappingTotality.AssertTotal( + static kind => FailureKinds.TryGetValue(kind, out CheatEngineFailureKind expected) && + RuntimeObservationMapping.ToFailureKind(kind) == expected, + static kind => RuntimeObservationMapping.ToFailureKind(kind) == + CheatEngineFailureKind.IndeterminateHostResult); + MappingTotality.AssertTotal( + static kind => HostEvidence.TryGetValue(kind, out ClientCapabilityEvidenceState expected) && + RuntimeObservationMapping.ToHostEvidenceState(kind) == expected, + static kind => RuntimeObservationMapping.ToHostEvidenceState(kind) == ClientCapabilityEvidenceState.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryTargetSelectionStatusMapsToWhatItEstablishesAboutTheIdentity() + { + MappingTotality.AssertTotal( + static status => SelectionIdentities.TryGetValue(status, out SelectionIdentity expected) && + RuntimeObservationMapping.ToSelectionIdentity(status) == expected, + static status => RuntimeObservationMapping.ToSelectionIdentity(status) == SelectionIdentity.Unavailable); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryIdentityCheckKindMapsToItsIncarnationComparison() + { + MappingTotality.AssertTotal( + static kind => IncarnationComparisons.TryGetValue(kind, out IncarnationComparison expected) && + RuntimeObservationMapping.ToIncarnationComparison(kind) == expected, + static kind => + RuntimeObservationMapping.ToIncarnationComparison(kind) == IncarnationComparison.Unavailable); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryLuaOperationStatusMapsToTheEvidenceOfTheFactItRead() + { + MappingTotality.AssertTotal( + static kind => FactEvidence.TryGetValue(kind, out ClientCapabilityEvidenceState expected) && + RuntimeObservationMapping.ToFactEvidenceState(kind) == expected, + static kind => RuntimeObservationMapping.ToFactEvidenceState(kind) == ClientCapabilityEvidenceState.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EverySdkCapabilityAvailabilityMapsToItsHostEvidence() + { + MappingTotality.AssertTotal( + static state => AvailabilityEvidence.TryGetValue(state, out ClientCapabilityEvidenceState expected) && + RuntimeObservationMapping.ToHostEvidenceState(state) == expected, + static state => + RuntimeObservationMapping.ToHostEvidenceState(state) == ClientCapabilityEvidenceState.Unknown); + } + + [Theory] + [InlineData(ProcessOperationStatusKind.Unknown)] + [InlineData(ProcessOperationStatusKind.TargetNotAttached)] + [InlineData(ProcessOperationStatusKind.SelectionNotConfirmed)] + [InlineData(ProcessOperationStatusKind.GlobalUnavailable)] + [InlineData(ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData(ProcessOperationStatusKind.InvalidResult)] + [InlineData(ProcessOperationStatusKind.TargetChanged)] + [InlineData(ProcessOperationStatusKind.FileAsProcessTarget)] + public void AFailureCarriesItsKindTheOperationAndTheHostEffectWithAStableMessage(ProcessOperationStatusKind kind) + { + CheatEngineFailure failure = RuntimeObservationMapping.ToFailure("Processes.GetCurrentProcess", + TargetObservations.Status(kind), CheatEngineHostEffect.Completed); + + Assert.Equal(FailureKinds[kind], failure.Kind); + Assert.Equal("Processes.GetCurrentProcess", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Null(failure.Exception); + Assert.False(string.IsNullOrWhiteSpace(failure.Message)); + } + + [Fact] + public void ASuccessfulStatusIsNotAFailure() + { + Assert.Throws(() => RuntimeObservationMapping.ToFailure("Processes.GetCurrentProcess", + ProcessOperationStatus.Success, CheatEngineHostEffect.Completed)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ScanOptionTranslationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ScanOptionTranslationTests.cs new file mode 100644 index 0000000..f5af722 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ScanOptionTranslationTests.cs @@ -0,0 +1,74 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.ValueScanning; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The AOB options and the value-scan first request share one translation of the public scan options, so both scan +/// routes send Cheat Engine the same protection text and fast-scan method for every filter and alignment. +/// +public sealed class ScanOptionTranslationTests +{ + [Fact] + public void BothScanRoutesWriteTheSameOptionsForEveryFilterAndAlignment() + { + ScanProtectionRequirement[] requirements = Enum.GetValues(); + ScanAlignment[] alignments = [ScanAlignment.None, ScanAlignment.AlignedTo(16), ScanAlignment.LastDigits("f0")]; + int compared = 0; + foreach (ScanAlignment alignment in alignments) + { + foreach (ScanProtectionRequirement executable in requirements) + { + foreach (ScanProtectionRequirement copyOnWrite in requirements) + { + foreach (ScanProtectionRequirement writable in requirements) + { + ScanProtectionFilter protection = new(executable, copyOnWrite, writable); + AobScanOptions aob = AobScanMapping.ToSdkOptions(protection, alignment); + FirstScanRequest values = ValueScanRequests.CreateFirst( + ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)).WithProtection(protection) + .WithAlignment(alignment)); + + Assert.Equal(aob.ProtectionFlags, values.ProtectionFlags); + Assert.Equal(aob.AlignmentMethod, values.FastScanMethod); + // The only difference is the positional request's empty parameter where the AOB options omit it. + Assert.Equal(aob.AlignmentParameter ?? string.Empty, values.AlignmentParameter); + compared++; + } + } + } + } + + Assert.Equal(alignments.Length * requirements.Length * requirements.Length * requirements.Length, compared); + } + + [Fact] + public void BothScanRoutesAcceptEveryOptionThePublicFactoriesCreate() + { + // One check serves both routes: every value a public constructor or factory creates is defined, and so are the + // default filter and alignment; only a tampered value throws, in the AOB and value-scan validation alike + // (PatternScannerTests and ValueScannerTests tamper one on each route). + foreach (ScanProtectionRequirement requirement in Enum.GetValues()) + { + ScanProtectionFilter filter = new(requirement, requirement, requirement); + Assert.True(ScanOptionTranslation.IsDefined(filter)); + } + + Assert.True(ScanOptionTranslation.IsDefined(default(ScanProtectionFilter))); + Assert.True(ScanOptionTranslation.IsDefined(default(ScanAlignment))); + Assert.True(ScanOptionTranslation.IsDefined(ScanAlignment.AlignedTo(4))); + Assert.True(ScanOptionTranslation.IsDefined(ScanAlignment.LastDigits("f0"))); + } + + [Fact] + public void OnlyTheAobOptionsOmitTheParameterWithoutAlignment() + { + Assert.Null(ScanOptionTranslation.ToFastScan(ScanAlignment.None, null).Parameter); + Assert.Equal(string.Empty, ScanOptionTranslation.ToFastScan(ScanAlignment.None, string.Empty).Parameter); + Assert.Equal("F0", ScanOptionTranslation.ToFastScan(ScanAlignment.LastDigits("f0"), null).Parameter); + Assert.Equal("16", ScanOptionTranslation.ToFastScan(ScanAlignment.AlignedTo(16), string.Empty).Parameter); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkRuntimeObservationPortTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkRuntimeObservationPortTests.cs new file mode 100644 index 0000000..dc0f08e --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SdkRuntimeObservationPortTests.cs @@ -0,0 +1,74 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The production runtime observation port reaches CheatEngine.SDK only through a Lua admission: without an enabled +/// plugin every member is refused before any Cheat Engine global is read (Q45). +/// +public sealed class SdkRuntimeObservationPortTests +{ + public static TheoryData Members => + [ + nameof(IRuntimeObservationPort.TryObserveRuntimeInfo), + nameof(IRuntimeObservationPort.ObserveHost), + nameof(IRuntimeObservationPort.TryGetCheatEngineFileVersion), + nameof(IRuntimeObservationPort.TryGetSystemArchitecture), + nameof(IRuntimeObservationPort.TryIsCheatEngine64Bit), + nameof(IRuntimeObservationPort.TryGetOperatingSystem), + nameof(ITargetObservationPort.ObserveCurrent), + nameof(ITargetObservationPort.ObserveTargetArchitecture), + nameof(ITargetObservationPort.TryGetConfiguredPointerSize), + nameof(IRuntimeObservationPort.ObserveSelection), + nameof(IRuntimeObservationPort.ValidateSelection) + ]; + + [Theory] + [Trait("Qualification", "Q45")] + [MemberData(nameof(Members))] + public void EveryObservationRequiresAnEnabledPluginContext(string member) + { + SdkRuntimeObservationPort port = SdkRuntimeObservationPort.Instance; + Action observe = member switch + { + nameof(IRuntimeObservationPort.TryObserveRuntimeInfo) => () => port.TryObserveRuntimeInfo(out _), + nameof(IRuntimeObservationPort.ObserveHost) => () => port.ObserveHost(out _), + nameof(IRuntimeObservationPort.TryGetCheatEngineFileVersion) => () => + port.TryGetCheatEngineFileVersion(out _), + nameof(IRuntimeObservationPort.TryGetSystemArchitecture) => () => port.TryGetSystemArchitecture(out _), + nameof(IRuntimeObservationPort.TryIsCheatEngine64Bit) => () => port.TryIsCheatEngine64Bit(out _), + nameof(IRuntimeObservationPort.TryGetOperatingSystem) => () => port.TryGetOperatingSystem(out _), + nameof(ITargetObservationPort.ObserveCurrent) => () => port.ObserveCurrent(out _), + nameof(ITargetObservationPort.ObserveTargetArchitecture) => () => port.ObserveTargetArchitecture(out _), + nameof(ITargetObservationPort.TryGetConfiguredPointerSize) => () => + port.TryGetConfiguredPointerSize(out _, out _), + nameof(IRuntimeObservationPort.ObserveSelection) => () => port.ObserveSelection(), + nameof(IRuntimeObservationPort.ValidateSelection) => () => + port.ValidateSelection(TargetObservations.Incarnation(42, 1_000)), + _ => throw new ArgumentOutOfRangeException(nameof(member), member, null) + }; + + Assert.Throws(observe); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void TheSelectionPortRequiresAnEnabledPluginContext() + { + // The one call that changes Cheat Engine's selection is refused before openProcess runs. + Assert.Throws(() => + SdkProcessSelectionPort.Instance.SelectAndObserve(new TargetProcessId(42), out _)); + } + + [Fact] + public void TheMemoryCodecContextPortObservesTargetsThroughTheSameReadOnlyOperations() + { + SdkMemoryCodecContextPort port = SdkMemoryCodecContextPort.Instance; + + Assert.Throws(() => port.ObserveCurrent(out _)); + Assert.Throws(() => port.ObserveTargetArchitecture(out _)); + Assert.Throws(() => port.TryGetConfiguredPointerSize(out _, out _)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs deleted file mode 100644 index 193ce53..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CheatEngine.Client.Core.Domains; -using CheatEngine.SDK.Engine.AddressList; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class SdkTableRecordMutationPortCoverageTests -{ - [Fact] - public void ParentRelationshipGuardFailsClosedForAnUnknownLinkStatus() - { - TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(42), - new MemoryRecordId(12), 1, - static _ => new ParentChainStep((ParentChainStepKind) 99, default)); - - Assert.Equal(TableRecordMutationStatus.HostRejected, status); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void ParentRelationshipGuardRequiresAPositiveTraversalBound(int maximumHops) - { - Assert.Throws(() => TableParentRelationshipGuard.Validate(new MemoryRecordId(42), - new MemoryRecordId(12), maximumHops, static _ => ParentChainStep.Root)); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs index c19a5c8..f417c2c 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs @@ -4,17 +4,42 @@ namespace CheatEngine.Client.Core.Tests.Domains; +/// +/// The production mutation port against the real CheatEngine.SDK 2.0.0 AddressListMutations, without a Lua +/// runtime: what the SDK decides before any Lua call reaches the port as an outcome, and its admission fault reaches +/// , whose SDK boundary translates it (TryContractTests). +/// public sealed class SdkTableRecordMutationPortTests { [Fact] - public void TrySetParentRejectsASelfReferentialRelationshipBeforeAccessingTheAddressList() + public void CheatEngineSdkRefusesASelfParentBeforeAnyLuaCall() { SdkTableRecordMutationPort port = new(); MemoryRecordId id = new(42); - TableRecordMutationStatus status = port.TrySetParent(id, id, out MemoryRecordSnapshot record); + TableRecordMutationOutcome outcome = port.TrySetParent(id, id, out MemoryRecordSnapshot record); - Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + Assert.Equal(TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.SelfParent), outcome); Assert.Equal(default, record); } + + [Fact] + public void TheParentTraversalLimitIsTheBoundTheContractDocuments() + { + // The port always passes this bound, never the SDK default overload; ITableClient.TrySetParent documents it. + Assert.Equal(4096, SdkTableRecordMutationPort.ParentTraversalHops); + } + + [Fact] + public void CommandsThatNeedTheLuaRuntimeLetItsAdmissionFaultReachTheSdkBoundary() + { + // No Lua runtime is attached in unit tests: AddressListMutations acquires its Lua operation itself and throws + // InvalidOperationException, which the port neither catches nor classifies. + SdkTableRecordMutationPort port = new(); + + Assert.Throws(() => port.TryDelete(new MemoryRecordId(7))); + Assert.Throws(() => port.TrySetActive(new MemoryRecordId(7), true)); + Assert.Throws(() => + port.TrySetParent(new MemoryRecordId(7), new MemoryRecordId(8), out _)); + } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs deleted file mode 100644 index ed8a7ab..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using CheatEngine.Client.Core.Domains; -using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Inspection; -using CheatEngine.Client.Results; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class SymbolRegistrationLeaseCleanupCoverageTests -{ - [Fact] - public void DisposeReleasesTheNameAndBecomesTerminalWhenUntrackingFails() - { - List events = []; - InlineDispatcher dispatcher = new(); - SymbolRegistrationLease lease = new( - new SymbolRegistration("fixture-symbol", Address.Zero), - dispatcher, - _ => - { - events.Add("untrack"); - throw new InvalidOperationException("untracking failed"); - }, - name => events.Add("unregister:" + name), - name => events.Add("release-name:" + name)); - - InvalidOperationException exception = Assert.Throws(lease.Dispose); - - Assert.Equal("untracking failed", exception.Message); - Assert.True(lease.IsReleased); - Assert.Equal(["unregister:fixture-symbol", "untrack", "release-name:fixture-symbol"], events); - Assert.Equal(1, dispatcher.InvocationCount); - - lease.Dispose(); - - Assert.Equal(1, dispatcher.InvocationCount); - Assert.Equal(["unregister:fixture-symbol", "untrack", "release-name:fixture-symbol"], events); - } - - private sealed class InlineDispatcher : ICheatEngineDispatcher - { - public int InvocationCount - { - get; - private set; - } - - public bool IsMainThread => true; - - public bool TryInvoke(Action callback, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(callback); - InvocationCount++; - callback(); - failure = default; - return true; - } - - public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(callback); - InvocationCount++; - result = callback(); - failure = default; - return true; - } - - public void Invoke(Action callback, CancellationToken cancellationToken = default) - { - _ = TryInvoke(callback, out _, cancellationToken); - } - - public TResult Invoke(Func callback, CancellationToken cancellationToken = default) - { - _ = TryInvoke(callback, out TResult result, out _, cancellationToken); - return result; - } - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs index a49fe6d..4b65447 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs @@ -1,117 +1,249 @@ +using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Core.Tests.TestSupport; using CheatEngine.Client.Inspection; using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; +using SymbolRegistrationLease = CheatEngine.Client.Core.Domains.SymbolRegistrationLease; + namespace CheatEngine.Client.Core.Tests.Domains; -public sealed class SymbolRegistrationLeaseTests +/// +/// The Client symbol lease over a fake CheatEngine.SDK release handle: every SDK release kind reaches the lease as its +/// mapped outcome, the activation-local name reservation follows the lease's end, and Dispose never throws. +/// +public sealed class SymbolRegistrationLeaseTests : IDisposable { - [Fact] - public void FailedDispatchKeepsTheLeaseTrackedUntilTheCleanupRetryUnregistersIt() + private const string Name = "fixture-symbol"; + + private readonly ControlledCoreLifetimeContext _context = new(); + private readonly CoreLifetime _lifetime; + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly List _releasedNames = []; + + public SymbolRegistrationLeaseTests() { - List events = []; - CoreResourceRegistry registry = new(); - RetriableDispatcher dispatcher = new() { RejectDispatch = true }; - SymbolRegistrationLease lease = new( - new SymbolRegistration("fixture-symbol", Address.Zero), - dispatcher, - registeredLease => - { - registry.Untrack(registeredLease); - events.Add("untrack"); - }, - _ => events.Add("unregister"), - _ => events.Add("release-name")); - registry.Track(lease); + _lifetime = new CoreLifetime(_context); + _dispatcher = new SdkMainThreadDispatcher(_lifetime, new InlineMainThreadInvoker()); + } - Assert.Throws(lease.Dispose); + public static TheoryData EverySdkReleaseKind => + [.. Enum.GetValues()]; - Assert.False(lease.IsReleased); - Assert.Empty(events); + public void Dispose() + { + _context.Dispose(); + } + + [Theory] + [Trait("Qualification", "Q16.b")] + [MemberData(nameof(EverySdkReleaseKind))] + public void EverySdkReleaseKindReachesTheLeaseAsItsMappedOutcome(SymbolRegistrationReleaseKind kind) + { + ScriptedHandle handle = new(kind); + SymbolRegistrationLease lease = CreateLease(handle); + LeaseReleaseOutcome expected = SdkReleaseOutcomes.FromSymbolRegistration(kind); + + LeaseReleaseOutcome outcome = lease.Release(); + + Assert.Equal(expected, outcome); + Assert.Equal(expected, lease.LastReleaseOutcome); + Assert.Equal(!expected.IsRetryable, lease.IsReleased); + string[] expectedReleasedNames = expected.IsRetryable ? [] : [Name]; + Assert.Equal(expectedReleasedNames, _releasedNames); + Assert.Equal(1, handle.Calls); + } + + [Fact] + [Trait("Qualification", "Q16.b")] + public void ASupersededLeaseEndsCompleteWithoutASecondSdkCall() + { + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.Superseded); + SymbolRegistrationLease lease = CreateLease(handle); + + LeaseReleaseOutcome outcome = lease.Release(); + LeaseReleaseOutcome repeated = lease.Release(); + lease.Dispose(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Superseded, CheatEngineHostEffect.NotStarted), outcome); + Assert.True(outcome.IsComplete); + Assert.Equal(outcome, repeated); + Assert.Equal(outcome, lease.LastReleaseOutcome); + Assert.Equal(1, handle.Calls); + Assert.Equal([Name], _releasedNames); + } - dispatcher.RejectDispatch = false; - registry.Dispose(); + [Fact] + [Trait("Qualification", "Q16.b")] + [Trait("Qualification", "Q43")] + public void AStaleRuntimeIsARefusedRuntimeChangeThatTheDeactivationReports() + { + // The SDK makes no call into a replaced Lua runtime, so the name may remain: manual recovery, never a retry. + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.StaleRuntime); + SymbolRegistrationLease lease = CreateLease(handle); + lease.Register(_lifetime); + LeaseReleaseOutcome outcome = lease.Release(); + CheatEngineOperationException report = Drain(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + outcome); + Assert.True(outcome.RequiresManualRecovery); Assert.True(lease.IsReleased); - Assert.Equal(["unregister", "untrack", "release-name"], events); - Assert.Equal(2, dispatcher.InvocationCount); + Assert.Equal(CheatEngineFailureKind.RuntimeChanged, report.Failure.Kind); + Assert.Equal(SymbolRegistrationLease.ReleaseOperation, report.Failure.Operation); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, report.Failure.HostEffect); + Assert.Equal(1, handle.Calls); } - private sealed class RetriableDispatcher : ICheatEngineDispatcher + [Fact] + [Trait("Qualification", "Q16.b")] + [Trait("Qualification", "Q43")] + public void AnIndeterminateCleanupIsUnconfirmedNeverRetriedAndReportedAtDeactivation() { - public int InvocationCount - { - get; - private set; - } + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.CleanupIndeterminate); + SymbolRegistrationLease lease = CreateLease(handle); + lease.Register(_lifetime); + + LeaseReleaseOutcome outcome = lease.Release(); + LeaseReleaseOutcome repeated = lease.Release(); + CheatEngineOperationException report = Drain(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + outcome); + Assert.Equal(outcome, repeated); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, report.Failure.Kind); + Assert.Equal(1, handle.Calls); + Assert.Equal([Name], _releasedNames); + } - public bool RejectDispatch + [Fact] + [Trait("Qualification", "Q43")] + public void AnUnavailableCleanupKeepsTheLeaseAndItsReservationUntilARetryReleasesIt() + { + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.CleanupUnavailable, + SymbolRegistrationReleaseKind.Released); + SymbolRegistrationLease lease = CreateLease(handle); + lease.Register(_lifetime); + + LeaseReleaseOutcome first = lease.Release(); + bool releasedAfterFirst = lease.IsReleased; + int reservationsAfterFirst = _releasedNames.Count; + _context.Stop(); + using (_lifetime.EnterCleanupScope()) { - get; - set; + _lifetime.DrainOwnedResourcesForDisable(); } - public bool IsMainThread => true; + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + first); + Assert.False(releasedAfterFirst); + Assert.Equal(0, reservationsAfterFirst); + Assert.True(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + lease.LastReleaseOutcome); + Assert.Equal(2, handle.Calls); + Assert.Equal([Name], _releasedNames); + } - public bool TryInvoke(Action callback, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) + [Fact] + [Trait("Qualification", "Q16.b")] + public void DisposeNeverThrowsWhenTheSdkReleaseFaultsAndTheFaultIsNeverRetried() + { + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.Released) { - ArgumentNullException.ThrowIfNull(callback); - InvocationCount++; - if (RejectDispatch) - { - failure = new CheatEngineFailure( - CheatEngineFailureKind.InvalidState, - "Test.Dispatch", - "Dispatch admission is closed."); - return false; - } + Fault = new InvalidOperationException("the SDK release faulted") + }; + SymbolRegistrationLease lease = CreateLease(handle); - callback(); - failure = default; - return true; - } + lease.Dispose(); + LeaseReleaseOutcome repeated = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + lease.LastReleaseOutcome); + Assert.True(lease.IsReleased); + Assert.Equal(lease.LastReleaseOutcome, repeated); + Assert.Equal(1, handle.Calls); + Assert.Equal([Name], _releasedNames); + } + + [Fact] + [Trait("Qualification", "Q16.b")] + public void DisposeNeverThrowsWhenDispatchIsRefusedAndTheLeaseKeepsItsReservation() + { + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.Released); + SymbolRegistrationLease lease = CreateLease(handle); + _context.Stop(); + + lease.Dispose(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + lease.LastReleaseOutcome); + Assert.False(lease.IsReleased); + Assert.Equal(0, handle.Calls); + Assert.Empty(_releasedNames); + } + + [Fact] + public void TheLeaseExposesTheRegisteredNameAndAddressAndRejectsMissingCollaborators() + { + SymbolRegistration registration = new(Name, new Address(0x401000)); + ScriptedHandle handle = new(SymbolRegistrationReleaseKind.Released); + + SymbolRegistrationLease lease = new(registration, handle, _dispatcher, _releasedNames.Add); + + Assert.Equal(Name, lease.Name); + Assert.Equal(new Address(0x401000), lease.Address); + Assert.Null(lease.LastReleaseOutcome); + Assert.Equal("handle", Assert.Throws(() => + new SymbolRegistrationLease(registration, null!, _dispatcher, _releasedNames.Add)).ParamName); + Assert.Equal("releaseName", Assert.Throws(() => + new SymbolRegistrationLease(registration, handle, _dispatcher, null!)).ParamName); + } + + private SymbolRegistrationLease CreateLease(ScriptedHandle handle) + { + return new SymbolRegistrationLease(new SymbolRegistration(Name, new Address(0x401000)), handle, _dispatcher, + _releasedNames.Add); + } - public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, - CancellationToken cancellationToken = default) + private TException Drain() + where TException : Exception + { + _context.Stop(); + using (_lifetime.EnterCleanupScope()) { - ArgumentNullException.ThrowIfNull(callback); - InvocationCount++; - if (RejectDispatch) - { - result = default!; - failure = new CheatEngineFailure( - CheatEngineFailureKind.InvalidState, - "Test.Dispatch", - "Dispatch admission is closed."); - return false; - } + return Assert.Throws(_lifetime.DrainOwnedResourcesForDisable); + } + } - result = callback(); - failure = default; - return true; + /// A fake CheatEngine.SDK release handle that returns scripted kinds in order and counts its calls. + private sealed class ScriptedHandle(params SymbolRegistrationReleaseKind[] kinds) : ISymbolRegistrationHandle + { + internal int Calls + { + get; + private set; } - public void Invoke(Action callback, CancellationToken cancellationToken = default) + internal Exception? Fault { - if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) - { - failure.Throw(); - } + get; + init; } - public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + public SymbolRegistrationReleaseKind Release() { - if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) + Calls++; + if (Fault is not null) { - return result; + throw Fault; } - failure.Throw(); - return default!; + return kinds[Math.Min(Calls - 1, kinds.Length - 1)]; } } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs index 3437224..0f92720 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs @@ -3,6 +3,7 @@ using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Results; using CheatEngine.Client.Tables; @@ -13,34 +14,71 @@ namespace CheatEngine.Client.Core.Tests.Domains; public sealed class TableClientCoverageTests { - [Fact] - public void TryFindRejectsAnUninitializedSearchWithoutDispatching() + /// + /// A default or tampered argument is a programming error: the Try and the throwing forms throw what the + /// argument's constructor throws for the same value, before any dispatch. The default update, search and + /// definition, which change or find nothing, are no exception. + /// + [Theory] + [InlineData("GetSnapshot.DefaultRequest", "request", typeof(ArgumentOutOfRangeException))] + [InlineData("Find.DefaultSearch", "search", typeof(ArgumentException))] + [InlineData("Find.UndefinedValueType", "search", typeof(ArgumentOutOfRangeException))] + [InlineData("Find.DefaultRequest", "request", typeof(ArgumentOutOfRangeException))] + [InlineData("Create.DefaultDefinition", "definition", typeof(ArgumentException))] + [InlineData("Create.UndefinedValueType", "definition", typeof(ArgumentOutOfRangeException))] + [InlineData("Update.DefaultUpdate", "update", typeof(ArgumentException))] + [InlineData("Update.UndefinedValueType", "update", typeof(ArgumentOutOfRangeException))] + [InlineData("GetHierarchy.DefaultRequest", "request", typeof(ArgumentOutOfRangeException))] + [InlineData("LoadTrustedTable.DefaultRequest", "request", typeof(ArgumentException))] + [InlineData("SaveTable.DefaultRequest", "request", typeof(ArgumentException))] + public void ADefaultOrTamperedArgumentThrowsWithoutDispatching(string entryPoint, string parameter, Type expected) { RejectingDispatcher dispatcher = new(Failure()); TableClient client = CreateClient(dispatcher); - - bool succeeded = client.TryFind(default, new MemoryRecordCollectionRequest(8), - out ImmutableArray records, - out CheatEngineFailure failure, TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.True(records.IsEmpty); - Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); - Assert.Equal("Tables.Find", failure.Operation); - Assert.Equal(0, dispatcher.InvocationCount); - } - - [Fact] - public void FindConvertsTheUninitializedSearchRejectionToAnOperationException() - { - RejectingDispatcher dispatcher = new(Failure()); - TableClient client = CreateClient(dispatcher); - - CheatEngineOperationException exception = Assert.Throws(() => - client.Find(default, new MemoryRecordCollectionRequest(8), TestContext.Current.CancellationToken)); - - Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); - Assert.Equal("Tables.Find", exception.Failure.Operation); + CancellationToken token = TestContext.Current.CancellationToken; + MemoryRecordId id = new(42); + MemoryRecordCollectionRequest request = new(8); + MemoryRecordSearch search = TamperedValues.WithBackingField(new MemoryRecordSearch("Ammo"), + nameof(MemoryRecordSearch.VariableType), (VariableType?) (VariableType) 99); + MemoryRecordDefinition definition = TamperedValues.WithBackingField( + new MemoryRecordDefinition("Ammo", "game.exe+10", "100", VariableType.Dword), + nameof(MemoryRecordDefinition.VariableType), (VariableType) 99); + MemoryRecordUpdate update = TamperedValues.WithBackingField(new MemoryRecordUpdate("Ammo"), + nameof(MemoryRecordUpdate.VariableType), (VariableType?) (VariableType) 99); + (Action TryForm, Action ThrowingForm) forms = entryPoint switch + { + "GetSnapshot.DefaultRequest" => (() => client.TryGetSnapshot(default, out _, out _, token), + () => client.GetSnapshot(default, token)), + "Find.DefaultSearch" => (() => client.TryFind(default, request, out _, out _, token), + () => client.Find(default, request, token)), + "Find.UndefinedValueType" => (() => client.TryFind(search, request, out _, out _, token), + () => client.Find(search, request, token)), + "Find.DefaultRequest" => ( + () => client.TryFind(new MemoryRecordSearch("Ammo"), default, out _, out _, token), + () => client.Find(new MemoryRecordSearch("Ammo"), default, token)), + "Create.DefaultDefinition" => (() => client.TryCreate(default, out _, out _, token), + () => client.Create(default, token)), + "Create.UndefinedValueType" => (() => client.TryCreate(definition, out _, out _, token), + () => client.Create(definition, token)), + "Update.DefaultUpdate" => (() => client.TryUpdate(id, default, out _, out _, token), + () => client.Update(id, default, token)), + "Update.UndefinedValueType" => (() => client.TryUpdate(id, update, out _, out _, token), + () => client.Update(id, update, token)), + "GetHierarchy.DefaultRequest" => (() => client.TryGetHierarchy(id, default, out _, out _, token), + () => client.GetHierarchy(id, default, token)), + "LoadTrustedTable.DefaultRequest" => (() => client.TryLoadTrustedTable(default, out _, token), + () => client.LoadTrustedTable(default, token)), + "SaveTable.DefaultRequest" => (() => client.TrySaveTable(default, out _, token), + () => client.SaveTable(default, token)), + _ => throw new ArgumentOutOfRangeException(nameof(entryPoint), entryPoint, null) + }; + + ArgumentException tryForm = Assert.ThrowsAny(forms.TryForm); + ArgumentException throwingForm = Assert.ThrowsAny(forms.ThrowingForm); + + Assert.IsType(expected, tryForm); + Assert.IsType(expected, throwingForm); + Assert.Equal(parameter, tryForm.ParamName); Assert.Equal(0, dispatcher.InvocationCount); } @@ -51,7 +89,7 @@ public void NegativeRecordIndexIsRejectedBeforeTheAddressListIsRead() TableClient client = CreateClient(dispatcher); Assert.Throws(() => - client.TryGetRecord(-1, out _, out _, TestContext.Current.CancellationToken)); + client.TryGetRecordAt(-1, out _, out _, TestContext.Current.CancellationToken)); Assert.Equal(0, dispatcher.InvocationCount); } @@ -104,15 +142,15 @@ public void ReadOperationsPropagateTheExactDispatcherFailureWithoutReadingSdkSta TableClient client = CreateClient(dispatcher); MemoryRecordId id = new(42); - Assert.False(client.TryGetCurrent(out AddressTableSnapshot current, out CheatEngineFailure currentFailure, + Assert.False(client.TryGetRecordCount(out int count, out CheatEngineFailure countFailure, TestContext.Current.CancellationToken)); - Assert.Equal(default, current); - Assert.Equal(expected, currentFailure); + Assert.Equal(0, count); + Assert.Equal(expected, countFailure); Assert.False(client.TryGetSnapshot(new MemoryRecordCollectionRequest(8), out AddressTableSnapshot snapshot, out CheatEngineFailure snapshotFailure, TestContext.Current.CancellationToken)); Assert.Equal(default, snapshot); Assert.Equal(expected, snapshotFailure); - Assert.False(client.TryGetRecord(0, out MemoryRecordSnapshot indexedRecord, + Assert.False(client.TryGetRecordAt(0, out MemoryRecordSnapshot indexedRecord, out CheatEngineFailure indexedFailure, TestContext.Current.CancellationToken)); Assert.Equal(default, indexedRecord); @@ -122,7 +160,7 @@ public void ReadOperationsPropagateTheExactDispatcherFailureWithoutReadingSdkSta TestContext.Current.CancellationToken)); Assert.Equal(default, identifiedRecord); Assert.Equal(expected, identifiedFailure); - Assert.False(client.TryGetSelected(out MemoryRecordSnapshot selectedRecord, + Assert.False(client.TryGetSelectedRecord(out MemoryRecordSnapshot selectedRecord, out CheatEngineFailure selectedFailure, TestContext.Current.CancellationToken)); Assert.Equal(default, selectedRecord); @@ -139,15 +177,15 @@ public void MutatingOperationsPropagateTheExactDispatcherFailureWithoutInvokingT TableClient client = new(dispatcher, CoreClientPolicy.SafeDefaults, mutations); MemoryRecordId id = new(42); MemoryRecordDefinition definition = new("Health", "game.exe+10", "100", VariableType.Dword); - MemoryRecordUpdate update = new(id, value: "101"); + MemoryRecordUpdate update = new(value: "101"); Assert.False(client.TryCreate(definition, out MemoryRecordSnapshot created, out CheatEngineFailure createFailure, TestContext.Current.CancellationToken)); Assert.Equal(default, created); Assert.Equal(expected, createFailure); - Assert.False(client.TryUpdate(update, out MemoryRecordSnapshot updated, out CheatEngineFailure updateFailure, - TestContext.Current.CancellationToken)); + Assert.False(client.TryUpdate(id, update, out MemoryRecordSnapshot updated, + out CheatEngineFailure updateFailure, TestContext.Current.CancellationToken)); Assert.Equal(default, updated); Assert.Equal(expected, updateFailure); Assert.False(client.TryDelete(id, out CheatEngineFailure deleteFailure, TestContext.Current.CancellationToken)); @@ -199,18 +237,38 @@ internal int InvocationCount private set; } - public TableRecordMutationStatus TryDelete(MemoryRecordId id) + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record) { InvocationCount++; - return TableRecordMutationStatus.Success; + record = default; + return TableRecordCreation.Created; } - public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + public TableRecordMutationOutcome TryDelete(MemoryRecordId id) + { + InvocationCount++; + return TableRecordMutationOutcome.Succeeded; + } + + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record) { InvocationCount++; record = default; - return TableRecordMutationStatus.Success; + return TableRecordMutationOutcome.Succeeded; + } + + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested) + { + InvocationCount++; + return TableActivationObservation.Of(MemoryRecordActivationOutcomeKind.Applied); + } + + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record) + { + InvocationCount++; + record = default; + return TableRecordMutationOutcome.Succeeded; } } @@ -254,7 +312,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -265,7 +323,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientGenerationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientGenerationTests.cs new file mode 100644 index 0000000..bb1e5b5 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientGenerationTests.cs @@ -0,0 +1,648 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// Record identifiers are bound to the table load in which this activation observed them (audit ch.14, A14-01, +/// A14-05, A14-27, A14-29, Q34): a trusted load that reached Cheat Engine makes earlier identifiers stale, and every +/// identifier-taking operation refuses a stale identifier before any dispatch and again on the main thread. The +/// scripted interleavings model concurrent workers whose dispatched callbacks the main thread runs one at a time. +/// +public sealed class TableClientGenerationTests : IDisposable +{ + private static readonly MemoryRecordId HandedOut = new(41); + + private readonly string _root = Directory.CreateTempSubdirectory("ce-client-table-generation-").FullName; + + public static TheoryData IdentifierTakingOperations => + [ + "GetRecord", + "Select", + "Update", + "Delete", + "SetActive", + "SetParentChild", + "SetParentParent", + "GetHierarchy", + "CreateUnderParent" + ]; + + public static TheoryData MutationsWithoutAnSdkCommand => + [ + "Create", + "CreateUnderParent", + "Update", + "Select" + ]; + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void SetActiveRefusesARecordIdCapturedBeforeATrustedTableLoad() + { + Fixture fixture = CreateFixture(); + HandOutAndLoad(fixture); + int dispatchedBefore = fixture.Dispatcher.InvocationCount; + + bool succeeded = fixture.Client.TrySetActive(HandedOut, true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Tables.SetActive", failure.Operation); + Assert.Equal(TableClient.StaleRecordIdentifierMessage, failure.Message); + Assert.Equal(dispatchedBefore, fixture.Dispatcher.InvocationCount); + Assert.Equal(0, fixture.Mutations.Calls); + Assert.Throws(() => + fixture.Client.SetActive(HandedOut, true, TestContext.Current.CancellationToken)); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void RecordIdReobservedAfterATrustedTableLoadIsAccepted() + { + Fixture fixture = CreateFixture(); + HandOutAndLoad(fixture); + + Assert.True(fixture.Client.TryGetRecordAt(0, out MemoryRecordSnapshot reobserved, out _, + TestContext.Current.CancellationToken)); + bool succeeded = fixture.Client.TrySetActive(HandedOut, true, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.Equal(HandedOut, reobserved.Id); + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(1, fixture.Mutations.Calls); + } + + [Theory] + [Trait("Qualification", "Q34")] + [MemberData(nameof(IdentifierTakingOperations))] + public void EveryIdTakingOperationRefusesAStaleRecordIdBeforeDispatch(string operation) + { + Fixture fixture = CreateFixture(); + HandOutAndLoad(fixture); + int dispatchedBefore = fixture.Dispatcher.InvocationCount; + + (bool succeeded, CheatEngineFailure failure) = InvokeIdTakingOperation(fixture, operation); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(TableClient.StaleRecordIdentifierMessage, failure.Message); + Assert.Equal(dispatchedBefore, fixture.Dispatcher.InvocationCount); + Assert.Equal(0, fixture.Mutations.Calls); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void SnapshotCopiedBeforeAConcurrentTrustedLoadHandsOutARefusedRecordId() + { + // Review interleaving: worker B's snapshot is copied on the main thread; worker A's trusted load then runs on the + // main thread and advances the generation; only then does B resume and observe its copy. B must not hand out the + // pre-load identifier as current. + Fixture fixture = CreateFixture(); + fixture.Dispatcher.AfterNextCallback(() => + Assert.True(fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), out _, + TestContext.Current.CancellationToken))); + + Assert.True(fixture.Client.TryGetRecordAt(0, out MemoryRecordSnapshot copiedBeforeLoad, out _, + TestContext.Current.CancellationToken)); + bool succeeded = fixture.Client.TrySetActive(copiedBeforeLoad.Id, true, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.Equal(HandedOut, copiedBeforeLoad.Id); + Assert.Equal(1, fixture.Client.TableGeneration); + Assert.Single(fixture.Files.Loads); + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(TableClient.StaleRecordIdentifierMessage, failure.Message); + Assert.Equal(0, fixture.Mutations.Calls); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void SnapshotCopiedAfterAConcurrentTrustedLoadKeepsItsRecordIdCurrent() + { + // Reverse interleaving: the load runs on the main thread, another worker's snapshot is copied and observed after + // it, and the loading worker resumes last. The generation advanced inside the load, so the later snapshot is + // current and its identifier is accepted instead of being turned stale by the loading worker. + Fixture fixture = CreateFixture(); + Assert.True(fixture.Client.TryGetRecordAt(0, out _, out _, TestContext.Current.CancellationToken)); + MemoryRecordSnapshot copiedAfterLoad = default; + fixture.Dispatcher.AfterNextCallback(() => + Assert.True(fixture.Client.TryGetRecordAt(0, out copiedAfterLoad, out _, + TestContext.Current.CancellationToken))); + + Assert.True(fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), out _, + TestContext.Current.CancellationToken)); + bool succeeded = fixture.Client.TrySetActive(copiedAfterLoad.Id, true, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.Equal(HandedOut, copiedAfterLoad.Id); + Assert.Equal(1, fixture.Client.TableGeneration); + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(1, fixture.Mutations.Calls); + } + + [Theory] + [Trait("Qualification", "Q34")] + [MemberData(nameof(IdentifierTakingOperations))] + public void EveryIdTakingOperationQueuedBehindAnInFlightTrustedLoadIsRefusedOnTheMainThread(string operation) + { + // The identifier is current when the caller checks it, but a trusted load dispatched by another worker runs on + // the main thread before this operation's callback: the callback checks again and never calls Cheat Engine. + Fixture fixture = CreateFixture(); + Assert.True(fixture.Client.TryGetRecordAt(0, out _, out _, TestContext.Current.CancellationToken)); + fixture.Dispatcher.BeforeNextCallback(() => + Assert.True(fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), out _, + TestContext.Current.CancellationToken))); + + (bool succeeded, CheatEngineFailure failure) = InvokeIdTakingOperation(fixture, operation); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(TableClient.StaleRecordIdentifierMessage, failure.Message); + Assert.Equal(1, fixture.Client.TableGeneration); + Assert.Single(fixture.Files.Loads); + Assert.Equal(0, fixture.Mutations.Calls); + Assert.Equal(1, fixture.Lookups.Calls); + } + + [Fact] + public void TrustedTableLoadThatReachedCheatEngineAdvancesTheGenerationEvenWhenItFails() + { + // The load may have cleared or replaced the table before it failed, so earlier identifiers are not trusted. + Fixture fixture = CreateFixture(fileStatus: LuaOperationStatus.LuaFailure(LuaStatus.RuntimeError)); + Assert.True(fixture.Client.TryGetRecordAt(0, out _, out _, TestContext.Current.CancellationToken)); + + bool loaded = fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile, merge: true), + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(loaded); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.DoesNotContain(fixture.TableFile.FullPath, failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, fixture.Client.TableGeneration); + Assert.Equal([(fixture.TableFile.FullPath, true)], fixture.Files.Loads); + Assert.False(fixture.Client.TryDelete(HandedOut, out CheatEngineFailure staleFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(CheatEngineFailureKind.InvalidState, staleFailure.Kind); + } + + [Fact] + public void AFaultedTrustedTableLoadIsTranslatedAndStillAdvancesTheGeneration() + { + // No SDK exception crosses TryLoadTrustedTable; the load may have run before CheatEngine.SDK raised. + InvalidOperationException fault = new("detached runtime"); + Fixture fixture = CreateFixture(fileFault: fault); + + bool loaded = fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(loaded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Same(fault, failure.Exception); + Assert.Equal(1, fixture.Client.TableGeneration); + } + + [Fact] + public void RefusedTableSavePathNeverReachesCheatEngine() + { + // The trust policy refuses the path before dispatch: CheatTableFiles.TrySave is never called. + Fixture fixture = CreateFixture(); + int dispatchedBefore = fixture.Dispatcher.InvocationCount; + string outside = Path.Combine(Path.GetTempPath(), "outside-" + Guid.NewGuid().ToString("N"), "table.ct"); + + bool saved = fixture.Client.TrySaveTable(new TableSaveRequest(new TrustedTableFile(outside)), + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, fixture.Files.Saves); + Assert.Equal(dispatchedBefore, fixture.Dispatcher.InvocationCount); + } + + [Theory] + [InlineData(LuaOperationStatusKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.NotStarted)] + [InlineData(LuaOperationStatusKind.LuaFailure, CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Started)] + public void TrustedTableSaveReportsTheStatusOfCheatTableFiles(LuaOperationStatusKind kind, + CheatEngineFailureKind expectedKind, CheatEngineHostEffect expectedEffect) + { + LuaOperationStatus status = kind == LuaOperationStatusKind.GlobalUnavailable + ? LuaOperationStatus.GlobalUnavailable + : LuaOperationStatus.LuaFailure(LuaStatus.FileError); + Fixture fixture = CreateFixture(fileStatus: status); + + bool saved = fixture.Client.TrySaveTable(new TableSaveRequest(fixture.TableFile), + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.Equal("Tables.SaveTable", failure.Operation); + Assert.DoesNotContain(fixture.TableFile.FullPath, failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, fixture.Files.Saves); + } + + [Fact] + public void RefusedTableLoadPathDoesNotAdvanceTheGenerationAndIsNeverRetriedThroughAnotherOverload() + { + // A14-27: the trust policy refuses the path before dispatch; the Client never retries it through another + // loadTable overload or a stream (ITableClient exposes no stream or byte load path at all). + Fixture fixture = CreateFixture(); + Assert.True(fixture.Client.TryGetRecordAt(0, out _, out _, TestContext.Current.CancellationToken)); + int dispatchedBefore = fixture.Dispatcher.InvocationCount; + string outside = Path.Combine(Path.GetTempPath(), "outside-" + Guid.NewGuid().ToString("N"), "table.ct"); + + bool loaded = fixture.Client.TryLoadTrustedTable(new TableLoadRequest(new TrustedTableFile(outside)), + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(loaded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, fixture.Client.TableGeneration); + Assert.Empty(fixture.Files.Loads); + Assert.Equal(0, fixture.Files.Saves); + Assert.Equal(dispatchedBefore, fixture.Dispatcher.InvocationCount); + Assert.True(fixture.Client.TrySetActive(HandedOut, true, out _, out _, TestContext.Current.CancellationToken)); + } + + [Theory] + [MemberData(nameof(MutationsWithoutAnSdkCommand))] + public void AMutationWithoutAnSdkCommandIsRefusedWhileATrustedLoadRuns(string operation) + { + // CheatEngine.SDK refuses its own Address List commands while CheatTableFiles.TryLoad runs on the calling thread + // (a script of the table being loaded calling the Client); creation, update and selection have no SDK command, + // so the Client refuses them the same way. An identifier handed out before the load is not stale yet: the + // generation advances when the load returns. + Fixture fixture = CreateFixture(); + Assert.True(fixture.Client.TryGetRecordAt(0, out _, out _, TestContext.Current.CancellationToken)); + (bool Succeeded, CheatEngineFailure Failure) duringLoad = default; + fixture.Files.OnLoad = () => duringLoad = InvokeMutationWithoutAnSdkCommand(fixture, operation); + + Assert.True(fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), out _, + TestContext.Current.CancellationToken)); + + Assert.False(duringLoad.Succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidState, duringLoad.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, duringLoad.Failure.HostEffect); + Assert.Equal(operation switch + { + "Update" => "Tables.Update", + "Select" => "Tables.SelectRecord", + _ => "Tables.Create" + }, duringLoad.Failure.Operation); + Assert.StartsWith("A table file is loading on Cheat Engine's main thread", duringLoad.Failure.Message, + StringComparison.Ordinal); + Assert.Null(duringLoad.Failure.Exception); + Assert.Equal(0, fixture.Mutations.Calls); + Assert.True(fixture.Client.TryCreate( + new MemoryRecordDefinition("added", "game.exe+30", "1", VariableType.Dword), out _, out _, + TestContext.Current.CancellationToken), + "The refusal ends with the load."); + Assert.Equal(1, fixture.Mutations.Calls); + } + + [Fact] + public void TheTrustedLoadRefusalEndsWhenTheLoadFaults() + { + Fixture fixture = CreateFixture(fileFault: new InvalidOperationException("detached runtime")); + (bool Succeeded, CheatEngineFailure Failure) duringLoad = default; + fixture.Files.OnLoad = () => duringLoad = InvokeMutationWithoutAnSdkCommand(fixture, "Create"); + + Assert.False(fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), out _, + TestContext.Current.CancellationToken)); + bool afterLoad = fixture.Client.TryCreate( + new MemoryRecordDefinition("added", "game.exe+30", "1", VariableType.Dword), out _, + out CheatEngineFailure afterFailure, TestContext.Current.CancellationToken); + + Assert.False(duringLoad.Succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidState, duringLoad.Failure.Kind); + Assert.True(afterLoad); + Assert.Equal(default, afterFailure); + Assert.Equal(1, fixture.Mutations.Calls); + } + + [Fact] + public void AnIdentifierThatWasNeverHandedOutIsNotJudged() + { + // Unknown provenance: the Client refuses only identifiers it handed out before the last trusted load. + Fixture fixture = CreateFixture(); + HandOutAndLoad(fixture); + + bool succeeded = fixture.Client.TrySetActive(new MemoryRecordId(7), true, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + } + + private Fixture CreateFixture(Exception? fileFault = null, LuaOperationStatus? fileStatus = null) + { + string tablePath = Path.Combine(_root, "trusted.ct"); + File.WriteAllText(tablePath, ""); + CountingDispatcher dispatcher = new(); + CountingMutationPort mutations = new(); + RecordingFilePort files = new(fileFault, fileStatus); + SingleRecordLookupPort lookups = new(); + TableClient client = new(dispatcher, new CoreClientPolicy([_root], false), mutations, null, lookups, files); + return new Fixture(client, dispatcher, mutations, lookups, files, new TrustedTableFile(tablePath)); + } + + private static (bool Succeeded, CheatEngineFailure Failure) InvokeIdTakingOperation(Fixture fixture, + string operation) + { + MemoryRecordId other = new(99); + CancellationToken token = TestContext.Current.CancellationToken; + return operation switch + { + "GetRecord" => (fixture.Client.TryGetRecord(HandedOut, out _, out CheatEngineFailure f, token), f), + "Select" => (fixture.Client.TrySelectRecord(HandedOut, out _, out CheatEngineFailure f, token), f), + "Update" => (fixture.Client.TryUpdate(HandedOut, new MemoryRecordUpdate("renamed"), out _, + out CheatEngineFailure f, token), f), + "Delete" => (fixture.Client.TryDelete(HandedOut, out CheatEngineFailure f, token), f), + "SetActive" => (fixture.Client.TrySetActive(HandedOut, false, out _, out CheatEngineFailure f, token), f), + "SetParentChild" => (fixture.Client.TrySetParent(HandedOut, other, out _, out CheatEngineFailure f, token), + f), + "SetParentParent" => (fixture.Client.TrySetParent(other, HandedOut, out _, out CheatEngineFailure f, + token), f), + "GetHierarchy" => (fixture.Client.TryGetHierarchy(HandedOut, new MemoryRecordHierarchyRequest(4, 16), + out _, out CheatEngineFailure f, token), f), + "CreateUnderParent" => (fixture.Client.TryCreate( + new MemoryRecordDefinition("child", "game.exe+30", "1", VariableType.Dword, HandedOut), out _, + out CheatEngineFailure f, token), f), + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null) + }; + } + + private static (bool Succeeded, CheatEngineFailure Failure) InvokeMutationWithoutAnSdkCommand(Fixture fixture, + string operation) + { + CancellationToken token = TestContext.Current.CancellationToken; + return operation switch + { + "Create" => (fixture.Client.TryCreate( + new MemoryRecordDefinition("added", "game.exe+30", "1", VariableType.Dword), out _, + out CheatEngineFailure f, token), f), + "CreateUnderParent" => (fixture.Client.TryCreate( + new MemoryRecordDefinition("child", "game.exe+30", "1", VariableType.Dword, HandedOut), out _, + out CheatEngineFailure f, token), f), + "Update" => (fixture.Client.TryUpdate(HandedOut, new MemoryRecordUpdate("renamed"), out _, + out CheatEngineFailure f, token), f), + "Select" => (fixture.Client.TrySelectRecord(HandedOut, out _, out CheatEngineFailure f, token), f), + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null) + }; + } + + private static void HandOutAndLoad(Fixture fixture) + { + Assert.True(fixture.Client.TryGetRecordAt(0, out MemoryRecordSnapshot handedOut, out _, + TestContext.Current.CancellationToken)); + Assert.Equal(HandedOut, handedOut.Id); + Assert.True(fixture.Client.TryLoadTrustedTable(new TableLoadRequest(fixture.TableFile), out _, + TestContext.Current.CancellationToken)); + Assert.Equal(1, fixture.Client.TableGeneration); + } + + private static MemoryRecordSnapshot Snapshot(MemoryRecordId id) + { + return new MemoryRecordSnapshot(id, 0, + new MemoryRecordContentSnapshot("Health", "game.exe+24", "100", VariableType.Dword), + new MemoryRecordStateSnapshot(null)); + } + + private sealed record Fixture( + TableClient Client, + CountingDispatcher Dispatcher, + CountingMutationPort Mutations, + SingleRecordLookupPort Lookups, + RecordingFilePort Files, + TrustedTableFile TableFile); + + private sealed class SingleRecordLookupPort : ITableRecordLookupPort + { + internal int Calls + { + get; + private set; + } + + public RecordLookupStatus TryGetRecord(int index, out MemoryRecordSnapshot record) + { + Calls++; + record = Snapshot(HandedOut); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record) + { + Calls++; + record = Snapshot(id); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record) + { + Calls++; + record = Snapshot(HandedOut); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table) + { + Calls++; + table = new AddressTableSnapshot([Snapshot(HandedOut)]); + return RecordLookupStatus.Success; + } + } + + private sealed class CountingMutationPort : ITableRecordMutationPort + { + internal int Calls + { + get; + private set; + } + + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record) + { + Calls++; + record = Snapshot(new MemoryRecordId(50)); + return TableRecordCreation.Created; + } + + public TableRecordMutationOutcome TryDelete(MemoryRecordId id) + { + Calls++; + return TableRecordMutationOutcome.Succeeded; + } + + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + Calls++; + record = Snapshot(childId); + return TableRecordMutationOutcome.Succeeded; + } + + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested) + { + Calls++; + return new TableActivationObservation(MemoryRecordActivationOutcomeKind.Applied, + MemoryRecordMutationProblem.None, Snapshot(id)); + } + + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record) + { + Calls++; + record = Snapshot(id); + return TableRecordMutationOutcome.Succeeded; + } + } + + private sealed class RecordingFilePort(Exception? fault, LuaOperationStatus? status) : ITableFilePort + { + internal List<(string Path, bool Merge)> Loads + { + get; + } = []; + + internal int Saves + { + get; + private set; + } + + /// Runs inside the load, like a script of the table being loaded calling the Client. + internal Action? OnLoad + { + get; + set; + } + + public LuaOperationStatus TryLoad(string path, bool merge) + { + Loads.Add((path, merge)); + OnLoad?.Invoke(); + if (fault is not null) + { + throw fault; + } + + return status ?? LuaOperationStatus.Success; + } + + public LuaOperationStatus TrySave(string path) + { + Saves++; + return status ?? LuaOperationStatus.Success; + } + } + + /// + /// Runs callbacks inline, like Cheat Engine's main thread runs dispatched work one at a time, and can script another + /// dispatched operation right before or right after the next callback. The scripted operation is what another + /// worker thread's call would run on the main thread between two steps of this caller. + /// + private sealed class CountingDispatcher : ICheatEngineDispatcher + { + private Action? _afterNextCallback; + private Action? _beforeNextCallback; + + internal int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + RunOnce(ref _beforeNextCallback); + callback(); + RunOnce(ref _afterNextCallback); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + RunOnce(ref _beforeNextCallback); + result = callback(); + RunOnce(ref _afterNextCallback); + failure = default; + return true; + } + + /// Runs on the "main thread" right before the next callback. + internal void BeforeNextCallback(Action interleaved) + { + _beforeNextCallback = interleaved; + } + + /// + /// Runs on the "main thread" right after the next callback, before the caller of + /// that callback resumes. + /// + internal void AfterNextCallback(Action interleaved) + { + _afterNextCallback = interleaved; + } + + private static void RunOnce(ref Action? interleaved) + { + // Cleared first: the interleaved operation dispatches through this dispatcher too. + Action? action = interleaved; + interleaved = null; + action?.Invoke(); + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(cancellationToken); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(cancellationToken); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientLookupTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientLookupTests.cs index f897c42..c3a7fb9 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientLookupTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientLookupTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Core.Domains; @@ -15,24 +16,83 @@ public sealed class TableClientLookupTests [Fact] public void TryGetRecordMapsAnUnavailableAddressListToCapabilityUnavailable() { - FakeRecordLookupPort lookups = new() { IndexStatus = RecordLookupStatus.AddressListUnavailable }; + FakeRecordLookupPort lookups = new() + { + IndexStatus = RecordLookupStatus.AddressListUnavailable + }; TableClient client = CreateClient(lookups); - bool succeeded = client.TryGetRecord(3, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + bool succeeded = client.TryGetRecordAt(3, out MemoryRecordSnapshot record, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); Assert.Equal(default, record); Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Tables.GetRecord", failure.Operation); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Tables.GetRecordAt", failure.Operation); Assert.Equal("Cheat Engine's Address List capability is unavailable.", failure.Message); Assert.Equal(3, lookups.LastIndex); } + /// A copy of the top-level records fails like every record lookup, named after its method. + [Theory] + [InlineData(nameof(RecordLookupStatus.AddressListUnavailable), CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.NotStarted)] + [InlineData(nameof(RecordLookupStatus.InvalidRecord), CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.Unknown)] + public void ACopyOfTheTopLevelRecordsFailsLikeEveryRecordLookup(string status, + CheatEngineFailureKind expectedKind, CheatEngineHostEffect expectedEffect) + { + FakeRecordLookupPort lookups = new() + { + TableStatus = Enum.Parse(status) + }; + TableClient client = CreateClient(lookups); + CancellationToken token = TestContext.Current.CancellationToken; + + bool copied = client.TryGetSnapshot(new MemoryRecordCollectionRequest(8), out AddressTableSnapshot table, + out CheatEngineFailure snapshotFailure, token); + bool found = client.TryFind(new MemoryRecordSearch("Ammo"), new MemoryRecordCollectionRequest(8), + out ImmutableArray records, out CheatEngineFailure findFailure, token); + + Assert.False(copied); + Assert.Equal(default, table); + Assert.Equal(expectedKind, snapshotFailure.Kind); + Assert.Equal(expectedEffect, snapshotFailure.HostEffect); + Assert.Equal("Tables.GetSnapshot", snapshotFailure.Operation); + Assert.False(found); + Assert.True(records.IsEmpty); + Assert.Equal(expectedKind, findFailure.Kind); + Assert.Equal(expectedEffect, findFailure.HostEffect); + Assert.Equal("Tables.Find", findFailure.Operation); + } + + [Fact] + public void FindNamesItselfWhenTheCopyExceedsTheLimit() + { + FakeRecordLookupPort lookups = new() + { + Table = [Snapshot(1, "Ammo"), Snapshot(2, "Health")] + }; + TableClient client = CreateClient(lookups); + + bool found = client.TryFind(new MemoryRecordSearch("Ammo"), new MemoryRecordCollectionRequest(1), + out ImmutableArray records, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(found); + Assert.True(records.IsEmpty); + Assert.Equal(CheatEngineFailureKind.ResultLimitExceeded, failure.Kind); + Assert.Equal("Tables.Find", failure.Operation); + } + [Fact] public void TryGetRecordPreservesNotFoundForAnAbsentRecord() { - FakeRecordLookupPort lookups = new() { IdStatus = RecordLookupStatus.NotFound }; + FakeRecordLookupPort lookups = new() + { + IdStatus = RecordLookupStatus.NotFound + }; TableClient client = CreateClient(lookups); MemoryRecordId id = new(42); @@ -48,18 +108,21 @@ public void TryGetRecordPreservesNotFoundForAnAbsentRecord() } [Fact] - public void TryGetSelectedMapsAMalformedRecordToInvalidHostResult() + public void TryGetSelectedRecordMapsAMalformedRecordToInvalidHostResult() { - FakeRecordLookupPort lookups = new() { SelectedStatus = RecordLookupStatus.InvalidRecord }; + FakeRecordLookupPort lookups = new() + { + SelectedStatus = RecordLookupStatus.InvalidRecord + }; TableClient client = CreateClient(lookups); - bool succeeded = client.TryGetSelected(out MemoryRecordSnapshot record, out CheatEngineFailure failure, + bool succeeded = client.TryGetSelectedRecord(out MemoryRecordSnapshot record, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); Assert.Equal(default, record); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Tables.GetSelected", failure.Operation); + Assert.Equal("Tables.GetSelectedRecord", failure.Operation); Assert.Equal("Cheat Engine did not return the expected Address List contract.", failure.Message); Assert.Equal(1, lookups.SelectedCalls); } @@ -68,7 +131,11 @@ public void TryGetSelectedMapsAMalformedRecordToInvalidHostResult() public void TryGetRecordReturnsThePortSnapshotWhenTheLookupSucceeds() { MemoryRecordSnapshot expected = Snapshot(42, "Health"); - FakeRecordLookupPort lookups = new() { IdStatus = RecordLookupStatus.Success, IdRecord = expected }; + FakeRecordLookupPort lookups = new() + { + IdStatus = RecordLookupStatus.Success, + IdRecord = expected + }; TableClient client = CreateClient(lookups); bool succeeded = client.TryGetRecord(new MemoryRecordId(42), out MemoryRecordSnapshot record, @@ -79,11 +146,195 @@ public void TryGetRecordReturnsThePortSnapshotWhenTheLookupSucceeds() Assert.Equal(default, failure); } + [Fact] + public void FindReturnsEveryRecordThatSharesADuplicatedDescription() + { + // A14-30: the Client has no single lookup by description; a search returns every match, never an arbitrary first. + FakeRecordLookupPort lookups = new() + { + Table = [Snapshot(1, "Ammo"), Snapshot(2, "Health"), Snapshot(3, "Ammo")] + }; + TableClient client = CreateClient(lookups); + + bool succeeded = client.TryFind(new MemoryRecordSearch("Ammo"), new MemoryRecordCollectionRequest(8), + out ImmutableArray records, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal([new MemoryRecordId(1), new MemoryRecordId(3)], records.Select(static record => record.Id)); + } + + [Fact] + public void GetSnapshotReportsTheMaterializationLimitWithoutCopyingRecords() + { + FakeRecordLookupPort lookups = new() + { + Table = [Snapshot(1, "Ammo"), Snapshot(2, "Health")] + }; + TableClient client = CreateClient(lookups); + + bool succeeded = client.TryGetSnapshot(new MemoryRecordCollectionRequest(1), out AddressTableSnapshot table, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, table); + Assert.Equal(CheatEngineFailureKind.ResultLimitExceeded, failure.Kind); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void GetRecordWithAnIndexBeyondTheTableReportsNotFoundWithoutALuaError() + { + // A14-31: an index past the end is an absent record, not a Lua error and not an exception. + FakeRecordLookupPort lookups = new() + { + IndexStatus = RecordLookupStatus.NotFound + }; + TableClient client = CreateClient(lookups); + + bool succeeded = client.TryGetRecordAt(1000, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.NotEqual(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Null(failure.Exception); + Assert.Equal(1000, lookups.LastIndex); + } + + /// A cancelled throwing lookup raises the cancellation exception before any Address List read. + [Fact] + public void GetRecordAtThrowsOperationCanceledExceptionWhenTheDispatchIsCancelled() + { + FakeRecordLookupPort lookups = new() + { + IndexRecord = Snapshot(7, "Health") + }; + TableClient client = CreateClient(lookups); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + CheatEngineOperationCanceledException exception = Assert.Throws(() => + client.GetRecordAt(3, cancellation.Token)); + + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); + Assert.Equal(0, lookups.LastIndex); + } + + /// + /// A hierarchy copy reads the child positions of each record below its reported count, and stops at the first + /// position where Cheat Engine returns no child: that position is reported with the record identifier and the + /// count, apart from a malformed record. + /// + [Fact] + public void AChildMissingBelowTheReportedCountIsReportedAtItsPosition() + { + FakeHierarchyRecord root = new(12, childCount: 3) + { + Children = + { + [0] = new FakeHierarchyRecord(13), + [2] = new FakeHierarchyRecord(15) + } + }; + TableClient client = CreateHierarchyClient(root); + + bool succeeded = client.TryGetHierarchy(new MemoryRecordId(12), new MemoryRecordHierarchyRequest(16, 4), + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, hierarchy); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Equal("Tables.GetHierarchy", failure.Operation); + Assert.Equal("Cheat Engine did not return child 1 of memory record 12, which reports 3 children.", + failure.Message); + Assert.NotEqual(TableMapping.InvalidContractMessage, failure.Message); + Assert.Equal([0, 1], root.RequestedChildren); + } + + [Fact] + public void AHierarchyCopyReadsEveryChildPositionBelowEachReportedCount() + { + FakeHierarchyRecord grandchild = new(14); + FakeHierarchyRecord first = new(13, childCount: 1) + { + Children = + { + [0] = grandchild + } + }; + FakeHierarchyRecord second = new(15); + FakeHierarchyRecord root = new(12, childCount: 2) + { + Children = + { + [0] = first, + [1] = second + } + }; + TableClient client = CreateHierarchyClient(root); + + bool succeeded = client.TryGetHierarchy(new MemoryRecordId(12), new MemoryRecordHierarchyRequest(16, 4), + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new MemoryRecordId(12), hierarchy.Record.Id); + Assert.Equal([new MemoryRecordId(13), new MemoryRecordId(15)], + hierarchy.Children.Select(static child => child.Record.Id)); + Assert.Equal(new MemoryRecordId(14), Assert.Single(hierarchy.Children[0].Children).Record.Id); + Assert.Empty(hierarchy.Children[1].Children); + Assert.Equal([0, 1], root.RequestedChildren); + Assert.Equal([0], first.RequestedChildren); + Assert.Empty(grandchild.RequestedChildren); + Assert.Empty(second.RequestedChildren); + } + + /// The root lookup of a hierarchy fails the way every other record lookup does. + [Theory] + [InlineData(nameof(RecordLookupStatus.AddressListUnavailable), CheatEngineFailureKind.CapabilityUnavailable, + "Cheat Engine's Address List capability is unavailable.")] + [InlineData(nameof(RecordLookupStatus.NotFound), CheatEngineFailureKind.NotFound, + "The requested Cheat Engine memory record was not found.")] + [InlineData(nameof(RecordLookupStatus.InvalidRecord), CheatEngineFailureKind.InvalidHostResult, + "Cheat Engine did not return the expected Address List contract.")] + public void AHierarchyRootLookupFailsLikeEveryRecordLookup(string status, CheatEngineFailureKind expectedKind, + string expectedMessage) + { + TableClient client = new(new InlineDispatcher(), CoreClientPolicy.SafeDefaults, + hierarchy: new FakeHierarchyPort(new FakeHierarchyRecord(12)) + { + Status = Enum.Parse(status) + }); + + bool succeeded = client.TryGetHierarchy(new MemoryRecordId(12), new MemoryRecordHierarchyRequest(16, 4), + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, hierarchy); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Tables.GetHierarchy", failure.Operation); + Assert.Equal(expectedMessage, failure.Message); + } + private static TableClient CreateClient(FakeRecordLookupPort lookups) { return new TableClient(new InlineDispatcher(), CoreClientPolicy.SafeDefaults, recordLookups: lookups); } + private static TableClient CreateHierarchyClient(FakeHierarchyRecord root) + { + return new TableClient(new InlineDispatcher(), CoreClientPolicy.SafeDefaults, + hierarchy: new FakeHierarchyPort(root)); + } + private static MemoryRecordSnapshot Snapshot(int id, string description) { return new MemoryRecordSnapshot( @@ -119,6 +370,25 @@ internal MemoryRecordSnapshot IdRecord init; } + internal MemoryRecordSnapshot IndexRecord + { + get; + init; + } + + internal MemoryRecordSnapshot[] Table + { + get; + init; + } = []; + + /// Gets a failed status that the table copy reports instead of copying . + internal RecordLookupStatus TableStatus + { + get; + init; + } = RecordLookupStatus.Success; + internal int LastIndex { get; @@ -140,7 +410,7 @@ internal int SelectedCalls public RecordLookupStatus TryGetRecord(int index, out MemoryRecordSnapshot record) { LastIndex = index; - record = default; + record = IndexStatus == RecordLookupStatus.Success ? IndexRecord : default; return IndexStatus; } @@ -157,6 +427,80 @@ public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record) record = default; return SelectedStatus; } + + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table) + { + if (TableStatus != RecordLookupStatus.Success) + { + table = default; + return TableStatus; + } + + if (Table.Length > maximumItems) + { + table = default; + return RecordLookupStatus.LimitExceeded; + } + + table = new AddressTableSnapshot([.. Table]); + return RecordLookupStatus.Success; + } + } + + private sealed class FakeHierarchyPort(FakeHierarchyRecord record) : ITableHierarchyPort + { + /// Gets or sets a failed status to report instead of the lookup, or Success to look up. + internal RecordLookupStatus Status + { + get; + init; + } = RecordLookupStatus.Success; + + public RecordLookupStatus TryGetRoot(MemoryRecordId id, out ITableHierarchyRecord? root) + { + if (Status != RecordLookupStatus.Success) + { + root = null; + return Status; + } + + root = id == record.Id ? record : null; + return root is null ? RecordLookupStatus.NotFound : RecordLookupStatus.Success; + } + } + + /// A record that reports a child count and returns the children it holds, recording each position read. + private sealed class FakeHierarchyRecord(int id, int childCount = 0) : ITableHierarchyRecord + { + internal MemoryRecordId Id + { + get; + } = new(id); + + internal Dictionary Children + { + get; + } = []; + + internal List RequestedChildren + { + get; + } = []; + + public bool TrySnapshot(out MemoryRecordSnapshot snapshot) + { + snapshot = new MemoryRecordSnapshot(Id, 0, + new MemoryRecordContentSnapshot("Group", "game.exe+24", "50", VariableType.Dword), + new MemoryRecordStateSnapshot(null, childCount: childCount)); + return true; + } + + public bool TryGetChild(int index, [NotNullWhen(true)] out ITableHierarchyRecord? child) + { + RequestedChildren.Add(index); + child = Children.GetValueOrDefault(index); + return child is not null; + } } private sealed class InlineDispatcher : ICheatEngineDispatcher @@ -198,7 +542,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -209,7 +553,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs index 70a494a..9163161 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs @@ -10,12 +10,30 @@ namespace CheatEngine.Client.Core.Tests.Domains; +/// +/// The Tables client over a fake mutation port: every outcome CheatEngine.SDK's AddressListMutations reports, +/// and every outcome of the Client's own create and select steps, reaches the public failure contract through +/// . +/// public sealed class TableClientMutationTests { + public static TheoryData ParentRefusals => new() + { + { MemoryRecordMutationProblem.CycleDetected, CheatEngineFailureKind.OperationRejected }, + { MemoryRecordMutationProblem.SelfParent, CheatEngineFailureKind.OperationRejected }, + { MemoryRecordMutationProblem.TraversalLimitReached, CheatEngineFailureKind.ResultLimitExceeded }, + { MemoryRecordMutationProblem.ParentNotFound, CheatEngineFailureKind.NotFound }, + { MemoryRecordMutationProblem.RecordNotFound, CheatEngineFailureKind.NotFound }, + { MemoryRecordMutationProblem.TableLoadInProgress, CheatEngineFailureKind.InvalidState }, + { MemoryRecordMutationProblem.RuntimeIdentityChanged, CheatEngineFailureKind.RuntimeChanged }, + { MemoryRecordMutationProblem.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable }, + { MemoryRecordMutationProblem.LuaFailure, CheatEngineFailureKind.LuaError } + }; + [Fact] public void TryDeleteDispatchesTheRequestedRecordAndReturnsSuccess() { - FakeRecordMutationPort mutations = new() { DeleteStatus = TableRecordMutationStatus.Success }; + FakeRecordMutationPort mutations = new(); TableClient client = CreateClient(mutations); bool succeeded = @@ -28,11 +46,12 @@ public void TryDeleteDispatchesTheRequestedRecordAndReturnsSuccess() } [Fact] + [Trait("Qualification", "Q34")] public void TryDeleteClassifiesAnAbsentRecord() { TableClient client = CreateClient(new FakeRecordMutationPort { - DeleteStatus = TableRecordMutationStatus.RecordNotFound + DeleteOutcome = TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.RecordNotFound) }); bool succeeded = @@ -41,22 +60,32 @@ public void TryDeleteClassifiesAnAbsentRecord() Assert.False(succeeded); Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); Assert.Equal("Tables.Delete", failure.Operation); + Assert.Equal(TableMapping.RecordNotFoundMessage, failure.Message); } [Fact] - public void DeleteThrowsTheClassifiedHostFailure() + [Trait("Qualification", "Q34")] + public void DeleteThrowsAStartedLuaFailureThatIsNeverRetried() { - TableClient client = CreateClient(new FakeRecordMutationPort + // AddressListMutations.Delete reports a destroy that raised after it started as Indeterminate: part of it may + // persist, and the Client never retries it. + FakeRecordMutationPort mutations = new() { - DeleteStatus = TableRecordMutationStatus.HostRejected - }); + DeleteOutcome = new TableRecordMutationOutcome(MemoryRecordMutationEffect.Indeterminate, + MemoryRecordMutationProblem.LuaFailure) + }; + TableClient client = CreateClient(mutations); CheatEngineOperationException exception = Assert.Throws(() => client.Delete(new MemoryRecordId(41), TestContext.Current.CancellationToken)); - Assert.Equal(CheatEngineFailureKind.InvalidHostResult, exception.Failure.Kind); + Assert.Equal(CheatEngineFailureKind.LuaError, exception.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, exception.Failure.HostEffect); Assert.Equal("Tables.Delete", exception.Failure.Operation); + Assert.Contains("effect is unknown", exception.Failure.Message, StringComparison.Ordinal); + Assert.Equal(1, mutations.DeleteCallCount); } [Fact] @@ -65,7 +94,7 @@ public void TrySetParentPassesBothIdsAndReturnsTheCopiedPostMutationSnapshot() MemoryRecordSnapshot expected = Snapshot(41, "Ammo"); FakeRecordMutationPort mutations = new() { - SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = expected + SetParentRecord = expected }; TableClient client = CreateClient(mutations); @@ -85,7 +114,7 @@ public void TrySetParentWithNullParentRequestsTheAddressListRoot() { FakeRecordMutationPort mutations = new() { - SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = Snapshot(41, "Ammo") + SetParentRecord = Snapshot(41, "Ammo") }; TableClient client = CreateClient(mutations); @@ -110,18 +139,44 @@ public void TrySetParentRejectsARecordAsItsOwnParentBeforeDispatching() Assert.False(succeeded); Assert.Equal(default, record); Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); Assert.Equal("Tables.SetParent", failure.Operation); - Assert.Equal( - "The requested parent relationship is invalid: it is self-referential, cyclic, or exceeds the " + - "supported hierarchy depth.", - failure.Message); + Assert.Equal("A memory record cannot be its own parent.", failure.Message); Assert.Equal(0, mutations.SetParentCallCount); } + [Theory] + [Trait("Qualification", "Q34")] + [MemberData(nameof(ParentRefusals))] + public void TrySetParentReportsEveryRefusalOfCheatEngineSdkAsNotStarted(MemoryRecordMutationProblem problem, + CheatEngineFailureKind expected) + { + FakeRecordMutationPort mutations = new() + { + SetParentOutcome = TableRecordMutationOutcome.NotAttempted(problem), + SetParentRecord = Snapshot(41, "Ammo") + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), + out MemoryRecordSnapshot record, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(expected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Tables.SetParent", failure.Operation); + Assert.Equal(1, mutations.SetParentCallCount); + } + [Fact] - public void TrySetParentMapsAHostRejectedMutationToTheExactHostFailure() + public void TrySetParentKeepsACompletedMoveApartFromAFailedCopyOfTheRecord() { - FakeRecordMutationPort mutations = new() { SetParentStatus = TableRecordMutationStatus.HostRejected }; + // CheatEngine.SDK asks callers never to merge a failed post-command read with the command result. + FakeRecordMutationPort mutations = new() + { + SetParentOutcome = TableRecordMutationOutcome.CompletedWithoutSnapshot + }; TableClient client = CreateClient(mutations); bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), @@ -129,19 +184,19 @@ public void TrySetParentMapsAHostRejectedMutationToTheExactHostFailure() Assert.False(succeeded); Assert.Equal(default, record); - Assert.Equal(new MemoryRecordId(41), mutations.LastChildId); - Assert.Equal(new MemoryRecordId(12), mutations.LastParentId); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Tables.SetParent", failure.Operation); - Assert.Equal("Cheat Engine did not return the expected Address List contract.", failure.Message); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("Cheat Engine completed the change, but the memory record could not be copied afterwards.", + failure.Message); } [Fact] + [Trait("Qualification", "Q34")] public void SetParentThrowsAClassifiedMissingParentFailure() { TableClient client = CreateClient(new FakeRecordMutationPort { - SetParentStatus = TableRecordMutationStatus.ParentNotFound + SetParentOutcome = TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.ParentNotFound) }); CheatEngineOperationException exception = Assert.Throws(() => @@ -153,69 +208,347 @@ public void SetParentThrowsAClassifiedMissingParentFailure() } [Fact] - public void ParentRelationshipGuardPermitsARootTerminatedCandidateChain() + [Trait("Qualification", "Q34")] + public void AMutationRefusedDuringATableLoadThrowsTheLifecycleException() { - TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), - new MemoryRecordId(12), 4, - static id => id == new MemoryRecordId(12) ? ParentChainStep.Root : ParentChainStep.HostRejected); + // InvalidState maps to CheatEngineInvalidStateException on the throwing form. + TableClient client = CreateClient(new FakeRecordMutationPort + { + DeleteOutcome = TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.TableLoadInProgress) + }); + + CheatEngineInvalidStateException exception = Assert.Throws(() => + client.Delete(new MemoryRecordId(41), TestContext.Current.CancellationToken)); - Assert.Equal(TableRecordMutationStatus.Success, status); + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, exception.Failure.HostEffect); } [Fact] - public void ParentRelationshipGuardPropagatesARejectedHostRead() + [Trait("Qualification", "Q43")] + public void FailedCreateWithUnconfirmedRollbackReportsCleanupUnconfirmed() { - TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), - new MemoryRecordId(12), 4, static _ => ParentChainStep.HostRejected); + InvalidOperationException rollbackFault = new("delete faulted"); + FakeRecordMutationPort mutations = new() + { + Creation = new TableRecordCreation( + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.ParentNotFound), + TableRecordRollback.Unconfirmed, RollbackFault: rollbackFault) + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TryCreate(Definition(), out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); - Assert.Equal(TableRecordMutationStatus.HostRejected, status); + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.Equal("Tables.Create", failure.Operation); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Contains("may remain in the Address List", failure.Message, StringComparison.Ordinal); + Assert.Same(rollbackFault, failure.Exception); + Assert.Equal(1, mutations.CreateCallCount); } [Fact] - public void ParentRelationshipGuardRejectsAnIndirectCycleBackToTheChild() + public void FailedCreateWithConfirmedRollbackReportsACompletedHostEffect() { - Dictionary links = new() + TableClient client = CreateClient(new FakeRecordMutationPort { - [new MemoryRecordId(12)] = ParentChainStep.Parent(new MemoryRecordId(24)), - [new MemoryRecordId(24)] = ParentChainStep.Parent(new MemoryRecordId(41)) + Creation = new TableRecordCreation(TableRecordMutationOutcome.InvalidResultAfterInvocation, + TableRecordRollback.Confirmed) + }); + + bool succeeded = client.TryCreate(Definition(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(TableMapping.InvalidContractMessage, failure.Message); + } + + [Fact] + public void FailedCreateKeepsBothTheCreationAndTheRollbackFaults() + { + InvalidOperationException creationFault = new("initialization faulted"); + InvalidOperationException rollbackFault = new("delete faulted"); + TableClient client = CreateClient(new FakeRecordMutationPort + { + Creation = new TableRecordCreation(TableRecordMutationOutcome.InvalidResultAfterInvocation, + TableRecordRollback.Unconfirmed, creationFault, rollbackFault) + }); + + bool succeeded = client.TryCreate(Definition(), out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + AggregateException aggregate = Assert.IsType(failure.Exception); + Assert.Collection( + aggregate.InnerExceptions, + first => Assert.Same(creationFault, first), + second => Assert.Same(rollbackFault, second)); + } + + [Fact] + public void SuccessfulCreateReturnsTheCopiedSnapshot() + { + MemoryRecordSnapshot expected = Snapshot(51, "Health"); + TableClient client = CreateClient(new FakeRecordMutationPort + { + CreatedRecord = expected + }); + + bool succeeded = client.TryCreate(Definition(), out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(expected, record); + } + + [Theory] + [Trait("Qualification", "Q35")] + [InlineData(true, "left the memory record inactive")] + [InlineData(false, "left the memory record active")] + public void TrySetActiveReportsARefusalByTheHostWithThePostChangeSnapshot(bool requested, string expectedMessage) + { + FakeRecordMutationPort mutations = new() + { + Activation = new TableActivationObservation(MemoryRecordActivationOutcomeKind.RefusedByHost, + MemoryRecordMutationProblem.None, Snapshot(41, "Health", !requested)) }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetActive(new MemoryRecordId(41), requested, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.Equal("Tables.SetActive", failure.Operation); + Assert.Contains(expectedMessage, failure.Message, StringComparison.Ordinal); + Assert.Contains("partial script effects may persist", failure.Message, StringComparison.Ordinal); + Assert.Equal(new MemoryRecordId(41), record.Id); + Assert.Equal(!requested, record.State.IsActive); + Assert.Equal([requested], mutations.RequestedStates); + } + + [Theory] + [Trait("Qualification", "Q35")] + [InlineData(MemoryRecordActivationOutcomeKind.Applied)] + [InlineData(MemoryRecordActivationOutcomeKind.Unchanged)] + public void TrySetActiveSucceedsWithThePostChangeSnapshot(MemoryRecordActivationOutcomeKind kind) + { + FakeRecordMutationPort mutations = new() + { + Activation = new TableActivationObservation(kind, MemoryRecordMutationProblem.None, + Snapshot(41, "Health", true)) + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetActive(new MemoryRecordId(41), true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.True(record.State.IsActive); + Assert.Equal(1, mutations.SetActiveCallCount); + } + + [Theory] + [InlineData(MemoryRecordActivationOutcomeKind.Applied, CheatEngineHostEffect.Completed)] + [InlineData(MemoryRecordActivationOutcomeKind.Unchanged, CheatEngineHostEffect.NotStarted)] + [InlineData(MemoryRecordActivationOutcomeKind.Pending, CheatEngineHostEffect.Started)] + public void TrySetActiveKeepsASuccessfulCommandApartFromAFailedCopyOfTheRecord( + MemoryRecordActivationOutcomeKind kind, CheatEngineHostEffect expectedEffect) + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + Activation = TableActivationObservation.Of(kind) + }); - TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), - new MemoryRecordId(12), 4, - id => links[id]); + bool succeeded = client.TrySetActive(new MemoryRecordId(41), true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); - Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); } [Fact] - public void ParentRelationshipGuardRejectsAnExistingParentLoop() + [Trait("Qualification", "Q35")] + public void TrySetActiveSucceedsForAnAsynchronousRecordStillProcessingWithItsSnapshotSayingSo() { - Dictionary links = new() + // Pending: the setter ran and the asynchronous activation is still processing; the snapshot copied after the + // command reports it, and a later snapshot observes the final state. + MemoryRecordSnapshot processing = new(new MemoryRecordId(41), 0, + new MemoryRecordContentSnapshot("Script", string.Empty, string.Empty, VariableType.Dword, "[ENABLE]"), + new MemoryRecordStateSnapshot(null, isActive: false, isAsync: true, isAsyncProcessing: true)); + TableClient client = CreateClient(new FakeRecordMutationPort { - [new MemoryRecordId(12)] = ParentChainStep.Parent(new MemoryRecordId(24)), - [new MemoryRecordId(24)] = ParentChainStep.Parent(new MemoryRecordId(12)) + Activation = new TableActivationObservation(MemoryRecordActivationOutcomeKind.Pending, + MemoryRecordMutationProblem.None, processing) + }); + + bool succeeded = client.TrySetActive(new MemoryRecordId(41), true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(processing, record); + Assert.True(record.State.IsAsyncProcessing); + } + + [Fact] + public void TrySetActiveReportsAStartedIndeterminateActivationWithoutASnapshot() + { + // A14-42: the setter ran, but CheatEngine.SDK could not establish the record's state after it. + TableClient client = CreateClient(new FakeRecordMutationPort + { + Activation = new TableActivationObservation(MemoryRecordActivationOutcomeKind.Indeterminate, + MemoryRecordMutationProblem.LuaFailure, Snapshot(41, "Health")) + }); + + bool succeeded = client.TrySetActive(new MemoryRecordId(41), true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.Equal(default, record); + } + + [Theory] + [Trait("Qualification", "Q34")] + [InlineData(MemoryRecordMutationProblem.RecordNotFound, CheatEngineFailureKind.NotFound)] + [InlineData(MemoryRecordMutationProblem.AddressListUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(MemoryRecordMutationProblem.TableLoadInProgress, CheatEngineFailureKind.InvalidState)] + [InlineData(MemoryRecordMutationProblem.RuntimeIdentityChanged, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(MemoryRecordMutationProblem.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] + public void TrySetActiveReportsAnActivationThatWasNotAttemptedAsNotStarted(MemoryRecordMutationProblem problem, + CheatEngineFailureKind expected) + { + FakeRecordMutationPort mutations = new() + { + Activation = TableActivationObservation.Of(MemoryRecordActivationOutcomeKind.NotAttempted, problem) }; + TableClient client = CreateClient(mutations); - TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), - new MemoryRecordId(12), 4, - id => links[id]); + bool succeeded = client.TrySetActive(new MemoryRecordId(41), true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); - Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(expected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(1, mutations.SetActiveCallCount); } [Fact] - public void ParentRelationshipGuardRejectsAChainThatExceedsItsBound() + public void TrySetActiveFailsClosedOnAnUnrecognizedActivationOutcome() { - TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), - new MemoryRecordId(12), 2, - static id => id switch - { - { Value: 12 } => ParentChainStep.Parent(new MemoryRecordId(24)), - { Value: 24 } => ParentChainStep.Parent(new MemoryRecordId(36)), - _ => ParentChainStep.Root - }); + TableClient client = CreateClient(new FakeRecordMutationPort + { + Activation = new TableActivationObservation(MemoryRecordActivationOutcomeKind.Unknown, + MemoryRecordMutationProblem.None, Snapshot(41, "Health")) + }); + + bool succeeded = client.TrySetActive(new MemoryRecordId(41), true, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void DeletingTheSameRecordTwiceReportsNotFoundTheSecondTime() + { + // A14-38: the second delete finds no record, so the record is never destroyed twice. + FakeRecordMutationPort mutations = new() + { + TrackDeletedRecords = true + }; + TableClient client = CreateClient(mutations); + + bool first = client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure firstFailure, + TestContext.Current.CancellationToken); + bool second = client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure secondFailure, + TestContext.Current.CancellationToken); - Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + Assert.True(first); + Assert.Equal(default, firstFailure); + Assert.False(second); + Assert.Equal(CheatEngineFailureKind.NotFound, secondFailure.Kind); + Assert.Equal(1, mutations.DestroyCount); + } + + [Fact] + public void SelectIsDispatchedAsAHostVisibleMutation() + { + // A14-08: changing Cheat Engine's GUI selection is a host-visible mutation, never a read of a cache. + FakeRecordMutationPort mutations = new() + { + SelectRecord = Snapshot(41, "Ammo") + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySelectRecord(new MemoryRecordId(41), out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new MemoryRecordId(41), record.Id); + Assert.Equal([new MemoryRecordId(41)], mutations.SelectedIds); + } + + [Fact] + public void MutationsReportCapabilityUnavailableWhenTheAddressListIsUnavailable() + { + // ADR-08: an unavailable Address List is the same capability condition for every mutation as for the lookups, + // never an unexpected host result; no record was reached. + TableRecordMutationOutcome unavailable = + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.AddressListUnavailable); + FakeRecordMutationPort mutations = new() + { + SelectOutcome = unavailable, + DeleteOutcome = unavailable, + SetParentOutcome = unavailable, + Creation = new TableRecordCreation(unavailable, TableRecordRollback.NotRequired), + Activation = TableActivationObservation.Of(MemoryRecordActivationOutcomeKind.NotAttempted, + MemoryRecordMutationProblem.AddressListUnavailable) + }; + TableClient client = CreateClient(mutations); + CancellationToken token = TestContext.Current.CancellationToken; + + bool selected = client.TrySelectRecord(new MemoryRecordId(41), out _, out CheatEngineFailure selectFailure, + token); + bool deleted = client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure deleteFailure, token); + bool reparented = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(7), out _, + out CheatEngineFailure parentFailure, token); + bool created = client.TryCreate(Definition(), out _, out CheatEngineFailure createFailure, token); + bool activated = client.TrySetActive(new MemoryRecordId(41), true, out _, out CheatEngineFailure activeFailure, + token); + + Assert.False(selected || deleted || reparented || created || activated); + foreach (CheatEngineFailure failure in (CheatEngineFailure[]) + [selectFailure, deleteFailure, parentFailure, createFailure, activeFailure]) + { + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(TableMapping.AddressListUnavailableMessage, failure.Message); + } + } + + private static MemoryRecordDefinition Definition() + { + return new MemoryRecordDefinition("Health", "game.exe+24", "100", VariableType.Dword); } private static TableClient CreateClient(FakeRecordMutationPort mutations) @@ -223,28 +556,32 @@ private static TableClient CreateClient(FakeRecordMutationPort mutations) return new TableClient(new InlineDispatcher(), CoreClientPolicy.SafeDefaults, mutations); } - private static MemoryRecordSnapshot Snapshot(int id, string description) + private static MemoryRecordSnapshot Snapshot(int id, string description, bool isActive = false) { return new MemoryRecordSnapshot( new MemoryRecordId(id), 0, new MemoryRecordContentSnapshot(description, "game.exe+24", "50", VariableType.Dword), - new MemoryRecordStateSnapshot(null)); + new MemoryRecordStateSnapshot(null, isActive)); } + /// + /// A scripted mutation port: each member returns its configured outcome in CheatEngine.SDK's mutation vocabulary + /// and records how it was called. + /// private sealed class FakeRecordMutationPort : ITableRecordMutationPort { - internal TableRecordMutationStatus DeleteStatus + internal TableRecordMutationOutcome DeleteOutcome { get; init; - } = TableRecordMutationStatus.Success; + } = TableRecordMutationOutcome.Succeeded; - internal TableRecordMutationStatus SetParentStatus + internal TableRecordMutationOutcome SetParentOutcome { get; init; - } = TableRecordMutationStatus.Success; + } = TableRecordMutationOutcome.Succeeded; internal MemoryRecordSnapshot SetParentRecord { @@ -252,12 +589,66 @@ internal MemoryRecordSnapshot SetParentRecord init; } + internal TableRecordCreation Creation + { + get; + init; + } = TableRecordCreation.Created; + + internal MemoryRecordSnapshot CreatedRecord + { + get; + init; + } + + internal TableActivationObservation Activation + { + get; + init; + } = new(MemoryRecordActivationOutcomeKind.Applied, MemoryRecordMutationProblem.None, Snapshot(41, "Health")); + + internal MemoryRecordSnapshot SelectRecord + { + get; + init; + } + + internal TableRecordMutationOutcome SelectOutcome + { + get; + init; + } = TableRecordMutationOutcome.Succeeded; + + internal bool TrackDeletedRecords + { + get; + init; + } + + internal int CreateCallCount + { + get; + private set; + } + + internal int DeleteCallCount + { + get; + private set; + } + internal MemoryRecordId LastDeletedId { get; private set; } + internal int DestroyCount + { + get; + private set; + } + internal MemoryRecordId LastChildId { get; @@ -276,20 +667,69 @@ internal int SetParentCallCount private set; } - public TableRecordMutationStatus TryDelete(MemoryRecordId id) + internal int SetActiveCallCount => RequestedStates.Count; + + internal List RequestedStates + { + get; + } = []; + + internal List SelectedIds + { + get; + } = []; + + private HashSet DeletedIds + { + get; + } = []; + + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record) { + CreateCallCount++; + record = Creation.Outcome.IsSuccess ? CreatedRecord : default; + return Creation; + } + + public TableRecordMutationOutcome TryDelete(MemoryRecordId id) + { + DeleteCallCount++; LastDeletedId = id; - return DeleteStatus; + if (!TrackDeletedRecords) + { + return DeleteOutcome; + } + + if (!DeletedIds.Add(id)) + { + return TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.RecordNotFound); + } + + DestroyCount++; + return TableRecordMutationOutcome.Succeeded; + } + + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested) + { + RequestedStates.Add(requested); + return Activation; + } + + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record) + { + SelectedIds.Add(id); + record = SelectOutcome.IsSuccess ? SelectRecord : default; + return SelectOutcome; } - public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record) { SetParentCallCount++; LastChildId = childId; LastParentId = parentId; record = SetParentRecord; - return SetParentStatus; + return SetParentOutcome; } } @@ -332,7 +772,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -343,7 +783,7 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableMappingTests.cs new file mode 100644 index 0000000..69ece00 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableMappingTests.cs @@ -0,0 +1,201 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// Every outcome of CheatEngine.SDK 2.0.0's Address List mutations and table files has a deliberate Client +/// counterpart, and a value the SDK could add later fails closed (Q48). +/// +public sealed class TableMappingTests +{ + private static readonly Dictionary ProblemKinds = new() + { + [MemoryRecordMutationProblem.Uninitialized] = CheatEngineFailureKind.IndeterminateHostResult, + [MemoryRecordMutationProblem.None] = CheatEngineFailureKind.IndeterminateHostResult, + [MemoryRecordMutationProblem.AddressListUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [MemoryRecordMutationProblem.RecordNotFound] = CheatEngineFailureKind.NotFound, + [MemoryRecordMutationProblem.ParentNotFound] = CheatEngineFailureKind.NotFound, + [MemoryRecordMutationProblem.SelfParent] = CheatEngineFailureKind.OperationRejected, + [MemoryRecordMutationProblem.CycleDetected] = CheatEngineFailureKind.OperationRejected, + [MemoryRecordMutationProblem.TraversalLimitReached] = CheatEngineFailureKind.ResultLimitExceeded, + [MemoryRecordMutationProblem.GlobalUnavailable] = CheatEngineFailureKind.CapabilityUnavailable, + [MemoryRecordMutationProblem.LuaFailure] = CheatEngineFailureKind.LuaError, + [MemoryRecordMutationProblem.InvalidResult] = CheatEngineFailureKind.InvalidHostResult, + [MemoryRecordMutationProblem.TableLoadInProgress] = CheatEngineFailureKind.InvalidState, + [MemoryRecordMutationProblem.RuntimeIdentityChanged] = CheatEngineFailureKind.RuntimeChanged + }; + + private static readonly Dictionary EffectKinds = new() + { + [MemoryRecordMutationEffect.NotAttempted] = CheatEngineHostEffect.NotStarted, + [MemoryRecordMutationEffect.Completed] = CheatEngineHostEffect.Completed, + [MemoryRecordMutationEffect.Indeterminate] = CheatEngineHostEffect.Started + }; + + private static readonly + Dictionary + ActivationResults = new() + { + [MemoryRecordActivationOutcomeKind.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + [MemoryRecordActivationOutcomeKind.Applied] = null, + [MemoryRecordActivationOutcomeKind.Unchanged] = null, + [MemoryRecordActivationOutcomeKind.RefusedByHost] = + (CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.Started), + [MemoryRecordActivationOutcomeKind.Pending] = null, + [MemoryRecordActivationOutcomeKind.Indeterminate] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Started), + // Refused before the setter: the problem decides the kind (here RecordNotFound). + [MemoryRecordActivationOutcomeKind.NotAttempted] = + (CheatEngineFailureKind.NotFound, CheatEngineHostEffect.NotStarted) + }; + + private static readonly + Dictionary + TableFileResults = new() + { + [LuaOperationStatusKind.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), + [LuaOperationStatusKind.Success] = null, + [LuaOperationStatusKind.GlobalUnavailable] = + (CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted), + [LuaOperationStatusKind.LuaFailure] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.NilResult] = (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.InvalidResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.StackUnavailable] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.NotStarted), + [LuaOperationStatusKind.MissingResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started), + [LuaOperationStatusKind.ResultCapacityExceeded] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Started) + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMutationProblemIsMappedAndAnUnknownProblemFailsClosed() + { + MappingTotality.AssertTotal( + static problem => ProblemKinds.TryGetValue(problem, out CheatEngineFailureKind expected) && + TableMapping.ToFailureKind(problem) == expected, + static problem => TableMapping.ToFailureKind(problem) == CheatEngineFailureKind.IndeterminateHostResult); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMutationEffectIsMappedAndAnUnknownEffectFailsClosed() + { + MappingTotality.AssertTotal( + static effect => EffectKinds.TryGetValue(effect, out CheatEngineHostEffect expected) && + TableMapping.ToHostEffect(effect) == expected, + static effect => TableMapping.ToHostEffect(effect) == CheatEngineHostEffect.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryActivationOutcomeIsMappedAndAnUnknownOutcomeFailsClosed() + { + MappingTotality.AssertTotal( + static kind => ActivationResults.TryGetValue(kind, + out (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect)? expected) && + TableMapping.ToActivationFailure(kind, MemoryRecordMutationProblem.RecordNotFound) == + expected, + static kind => TableMapping.ToActivationFailure(kind, MemoryRecordMutationProblem.None) == + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryTableFileStatusIsMappedAndAnUnknownStatusFailsClosed() + { + MappingTotality.AssertTotal( + static status => TableFileResults.TryGetValue(status, + out (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect)? expected) && + TableMapping.ToTableFileFailure(status) == expected, + static status => TableMapping.ToTableFileFailure(status) == + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown)); + } + + [Theory] + [Trait("Qualification", "Q34")] + [InlineData(true)] + [InlineData(false)] + public void TableFileFailuresNameOnlyTheirOwnAction(bool load) + { + // The mapping never receives the path; TableClientGenerationTests proves through TableClient that no failure + // message of a load or a save contains it. + string operation = load ? "Tables.LoadTrustedTable" : "Tables.SaveTable"; + foreach (LuaOperationStatusKind status in Enum.GetValues()) + { + bool succeeded = TableMapping.TryClassifyTableFile(operation, load, status, out CheatEngineFailure failure); + + Assert.Equal(status == LuaOperationStatusKind.Success, succeeded); + if (!succeeded) + { + Assert.Equal(operation, failure.Operation); + Assert.DoesNotContain(load ? "save" : "load", failure.Message, StringComparison.Ordinal); + } + } + } + + [Fact] + public void AnUnrecognizedMutationProblemNeverReportsAnEstablishedEffect() + { + // A NotAttempted outcome without a recognized problem is not a shape CheatEngine.SDK produces. + CheatEngineFailure uninitialized = TableMapping.MutationFailure("Tables.Delete", default); + CheatEngineFailure noProblem = TableMapping.MutationFailure("Tables.Delete", + new TableRecordMutationOutcome(MemoryRecordMutationEffect.Indeterminate, MemoryRecordMutationProblem.None)); + CheatEngineFailure undefined = TableMapping.MutationFailure("Tables.Delete", + TableRecordMutationOutcome.NotAttempted(MappingTotality.Undefined())); + + Assert.All((CheatEngineFailure[]) [uninitialized, noProblem, undefined], static failure => + { + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Equal("Tables.Delete", failure.Operation); + }); + } + + [Fact] + public void TheDefaultMutationOutcomeIsNeverASuccess() + { + Assert.False(default(TableRecordMutationOutcome).IsSuccess); + Assert.False(TableRecordMutationOutcome.CompletedWithoutSnapshot.IsSuccess); + Assert.True(TableRecordMutationOutcome.Succeeded.IsSuccess); + } + + [Theory] + [InlineData(MemoryRecordActivationOutcomeKind.Applied, true)] + [InlineData(MemoryRecordActivationOutcomeKind.Unchanged, true)] + [InlineData(MemoryRecordActivationOutcomeKind.Pending, true)] + [InlineData(MemoryRecordActivationOutcomeKind.RefusedByHost, true)] + [InlineData(MemoryRecordActivationOutcomeKind.Indeterminate, false)] + [InlineData(MemoryRecordActivationOutcomeKind.NotAttempted, false)] + [InlineData(MemoryRecordActivationOutcomeKind.Unknown, false)] + public void TheRecordIsCopiedOnlyAfterAnActivationWhoseOutcomeIsKnown(MemoryRecordActivationOutcomeKind kind, + bool copied) + { + Assert.Equal(copied, TableMapping.CopiesRecord(kind)); + } + + [Fact] + public void MutationMessagesNameTheCategoryAndTheTraversalBound() + { + CheatEngineFailure traversal = TableMapping.MutationFailure("Tables.SetParent", + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.TraversalLimitReached)); + CheatEngineFailure started = TableMapping.MutationFailure("Tables.Delete", + new TableRecordMutationOutcome(MemoryRecordMutationEffect.Indeterminate, + MemoryRecordMutationProblem.LuaFailure)); + CheatEngineFailure refused = TableMapping.MutationFailure("Tables.Delete", + TableRecordMutationOutcome.NotAttempted(MemoryRecordMutationProblem.LuaFailure)); + + Assert.Contains("4096", traversal.Message, StringComparison.Ordinal); + Assert.Equal(CheatEngineHostEffect.Started, started.HostEffect); + Assert.Contains("after the change started", started.Message, StringComparison.Ordinal); + Assert.Equal(CheatEngineHostEffect.NotStarted, refused.HostEffect); + Assert.Contains("before the change was attempted", refused.Message, StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TargetArchitectureObserverTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TargetArchitectureObserverTests.cs new file mode 100644 index 0000000..4c273a2 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TargetArchitectureObserverTests.cs @@ -0,0 +1,199 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Domains; + +/// +/// The one target observation policy: the SDK's bracketed observation answers, and a broken fact narrows the +/// observation to the PID, the bitness and the configured size instead of discarding them (audit F08, Q31, Q32). +/// +public sealed class TargetArchitectureObserverTests +{ + [Fact] + [Trait("Qualification", "Q32")] + public void ObserverReturnsTheSdkObservationWithItsOwnIsaDerivation() + { + TargetObservationDouble port = new() + { + Target = TargetObservations.Create(processId: 77, is64Bit: false, backend: TargetBackend.CEServer) + }; + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + + Assert.True(observed.HasTarget); + Assert.Null(observed.NarrowedFrom); + Assert.Equal(77, observed.ProcessId!.Value.Value); + Assert.Equal(TargetBackend.CEServer, observed.Backend); + Assert.Equal(CheatEngineArchitecture.X86, observed.Architecture); + Assert.Equal(PointerSize.Bit32, observed.Bitness); + Assert.Equal(TargetAbi.Windows, observed.Abi); + Assert.Equal([nameof(ITargetObservationPort.ObserveTargetArchitecture)], port.TargetCalls); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(ProcessOperationStatusKind.TargetNotAttached, TargetBackend.Unknown)] + [InlineData(ProcessOperationStatusKind.FileAsProcessTarget, TargetBackend.FileAsProcess)] + [InlineData(ProcessOperationStatusKind.TargetChanged, TargetBackend.Unknown)] + [InlineData(ProcessOperationStatusKind.GlobalUnavailable, TargetBackend.Unknown)] + public void ObserverAttributesNoFactWithoutASelectedProcessOrAfterATargetChange(ProcessOperationStatusKind kind, + TargetBackend expectedBackend) + { + // Spike C3 D2: with no target Cheat Engine reports x64-like facts, so the SDK reads none; nothing is re-read. + TargetObservationDouble port = new() + { + TargetStatus = TargetObservations.Status(kind) + }; + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + + Assert.False(observed.HasTarget); + Assert.Equal(kind == ProcessOperationStatusKind.TargetNotAttached, observed.NoTargetSelected); + Assert.Equal(expectedBackend, observed.Backend); + Assert.Null(observed.ProcessId); + Assert.Equal(PointerSize.Unknown, observed.Bitness); + Assert.Equal(CheatEngineArchitecture.Unknown, observed.Architecture); + Assert.Null(observed.ConfiguredPointerSizeBytes); + Assert.False(observed.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal([nameof(ITargetObservationPort.ObserveTargetArchitecture)], port.TargetCalls); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData(ProcessOperationStatusKind.InvalidResult)] + public void ObserverNarrowsABrokenFactToThePidTheBitnessAndTheConfiguredSize(ProcessOperationStatusKind kind) + { + TargetObservationDouble port = new() + { + TargetStatus = TargetObservations.Status(kind), + Target = TargetObservations.Create(processId: 42, configuredPointerSizeBytes: 4) + }; + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + + Assert.True(observed.HasTarget); + Assert.Equal(kind, observed.NarrowedFrom!.Value.Kind); + Assert.Equal(42, observed.ProcessId!.Value.Value); + Assert.Equal(PointerSize.Bit64, observed.Bitness); + Assert.Equal(4, observed.ConfiguredPointerSizeBytes); + Assert.True(observed.ConfiguredPointerSizeDiffersFromBitness); + // The narrowed reads establish neither the backend nor the ISA: nothing is inferred from the bitness. + Assert.Equal(TargetBackend.Unknown, observed.Backend); + Assert.Equal(CheatEngineArchitecture.Unknown, observed.Architecture); + Assert.Equal(TargetAbi.Unknown, observed.Abi); + Assert.Equal( + [ + nameof(ITargetObservationPort.ObserveTargetArchitecture), nameof(ITargetObservationPort.ObserveCurrent), + nameof(ITargetObservationPort.TryGetConfiguredPointerSize), nameof(ITargetObservationPort.ObserveCurrent) + ], port.TargetCalls); + } + + [Theory] + [Trait("Qualification", "Q31")] + [InlineData(2, ProcessOperationStatusKind.InvalidResult, 2)] + [InlineData(0, ProcessOperationStatusKind.InvalidResult, null)] + [InlineData(null, ProcessOperationStatusKind.GlobalUnavailable, null)] + [InlineData(8, ProcessOperationStatusKind.TargetChanged, null)] + public void NarrowingKeepsARawConfiguredSizeOnlyWhenAnIntegerWasRead(int? raw, ProcessOperationStatusKind status, + int? expected) + { + // Cheat Engine accepts any configured size (spike C3 D3(b)); the SDK keeps the raw integer of an invalid width. + TargetObservationDouble port = new() + { + TargetStatus = TargetObservations.LuaFailure, + Target = TargetObservations.Create(configuredPointerSizeBytes: raw), + ConfiguredStatus = TargetObservations.Status(status) + }; + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + + Assert.True(observed.HasTarget); + Assert.Equal(expected, observed.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit64, observed.Bitness); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(ProcessOperationStatusKind.Success, 43)] + [InlineData(ProcessOperationStatusKind.TargetNotAttached, 0)] + [InlineData(ProcessOperationStatusKind.FileAsProcessTarget, 0)] + public void ANarrowedObservationWhoseClosingSelectionDiffersIsATargetChange(ProcessOperationStatusKind closing, + int closingProcessId) + { + TargetObservationDouble port = new() + { + TargetStatus = TargetObservations.LuaFailure, + CurrentReads = + [ + (ProcessOperationStatus.Success, 42), (TargetObservations.Status(closing), closingProcessId) + ] + }; + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + + Assert.False(observed.HasTarget); + Assert.Equal(ProcessOperationStatusKind.TargetChanged, observed.Status.Kind); + Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, observed.NarrowedFrom!.Value.Kind); + Assert.Equal(PointerSize.Unknown, observed.Bitness); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void ANarrowedObservationKeepsTheFailureOfItsSelectionReads() + { + // A failed read is a failed read, not evidence of a different target. + TargetObservationDouble opening = new() + { + TargetStatus = TargetObservations.LuaFailure, + CurrentReads = [(ProcessOperationStatus.TargetNotAttached, 0)] + }; + TargetObservationDouble closing = new() + { + TargetStatus = ProcessOperationStatus.InvalidResult, + CurrentReads = [(ProcessOperationStatus.Success, 42), (TargetObservations.LuaFailure, 0)] + }; + + ObservedTarget openingObserved = TargetArchitectureObserver.Observe(opening); + ObservedTarget closingObserved = TargetArchitectureObserver.Observe(closing); + + Assert.True(openingObserved.NoTargetSelected); + Assert.Equal(2, opening.TargetCalls.Count); + Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, closingObserved.Status.Kind); + Assert.Equal(ProcessOperationStatusKind.InvalidResult, closingObserved.NarrowedFrom!.Value.Kind); + Assert.False(closingObserved.HasTarget); + } + + [Theory] + [Trait("Qualification", "Q31")] + [InlineData(true, 4, true)] + [InlineData(true, 8, false)] + [InlineData(false, 8, true)] + [InlineData(true, null, false)] + public void TheMismatchIsReportedOnlyBetweenKnownFacts(bool is64Bit, int? configured, bool differs) + { + TargetObservationDouble port = new() + { + Target = TargetObservations.Create(is64Bit: is64Bit, configuredPointerSizeBytes: configured) + }; + + ObservedTarget observed = TargetArchitectureObserver.Observe(port); + + Assert.Equal(differs, observed.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(configured, observed.ConfiguredPointerSizeBytes); + Assert.Equal(configured switch + { + 4 => PointerSize.Bit32, + 8 => PointerSize.Bit64, + _ => PointerSize.Unknown + }, observed.ConfiguredPointerSize); + } + + [Fact] + public void ObserverRejectsANullPort() + { + Assert.Throws(() => TargetArchitectureObserver.Observe(null!)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs deleted file mode 100644 index d7594f9..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs +++ /dev/null @@ -1,403 +0,0 @@ -using CheatEngine.Client.Allocations; -using CheatEngine.Client.Assembly; -using CheatEngine.Client.Core.Domains.Allocations; -using CheatEngine.Client.Core.Domains.Assembly; -using CheatEngine.Client.Core.Domains.Dbvm; -using CheatEngine.Client.Core.Domains.Debugger; -using CheatEngine.Client.Core.Domains.Hashing; -using CheatEngine.Client.Core.Domains.Hotkeys; -using CheatEngine.Client.Core.Domains.RemoteExecution; -using CheatEngine.Client.Core.Domains.Speed; -using CheatEngine.Client.Core.Domains.Timers; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; -using CheatEngine.Client.Events; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; -using CheatEngine.Client.RemoteExecution; -using CheatEngine.Client.Results; -using CheatEngine.Client.Speed; -using CheatEngine.Client.Timers; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class UnavailableAdvancedClientsTests -{ - private static readonly Address _address = new(0x401000); - - [Fact] - public void AllocationTryOperationReportsTheLiveGateAndLeavesNoLease() - { - UnavailableAllocationClient client = new(); - - bool succeeded = client.TryAllocate(new TargetAllocationRequest(4096), out ITargetMemoryLease? lease, - out CheatEngineFailure failure, TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Null(lease); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Allocations.Allocate", failure.Operation); - Assert.Contains("Cheat Engine 7.7 x64 live gate", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void AllocationThrowingOperationPreservesTheUnavailableFailure() - { - UnavailableAllocationClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.Allocate(new TargetAllocationRequest(1), TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void AssemblyTryOperationsReturnDefaultsAndAStableCapabilityFailure() - { - UnavailableAssemblyClient client = new(); - - Assert.False(client.TryDisassemble(_address, out AssemblyInstructionSnapshot instruction, - out CheatEngineFailure disassemble, - TestContext.Current.CancellationToken)); - Assert.Equal(default, instruction); - Assert.Equal("Assembly.Disassemble", disassemble.Operation); - - Assert.False(client.TryGetInstructionSize(_address, out int size, out CheatEngineFailure sizeFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(0, size); - Assert.Equal("Assembly.GetInstructionSize", sizeFailure.Operation); - - Assert.False(client.TryGetPreviousInstruction(_address, out Address previous, - out CheatEngineFailure previousFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(default, previous); - Assert.Equal("Assembly.GetPreviousInstruction", previousFailure.Operation); - - Assert.False(client.TryGetComment(_address, out string? comment, out CheatEngineFailure commentFailure, - TestContext.Current.CancellationToken)); - Assert.Null(comment); - Assert.Equal("Assembly.GetComment", commentFailure.Operation); - - Assert.False(client.TryAssemble(new AssemblyInstructionRequest(_address, "nop"), out _, - out CheatEngineFailure assemble, - TestContext.Current.CancellationToken)); - Assert.Equal("Assembly.Assemble", assemble.Operation); - - Assert.False(client.TryApplyPatch(new AutoAssemblerScript("[ENABLE]\n[DISABLE]"), - out IAutoAssemblerPatchLease? patch, out CheatEngineFailure patchFailure, - TestContext.Current.CancellationToken)); - Assert.Null(patch); - Assert.Equal("Assembly.ApplyPatch", patchFailure.Operation); - } - - [Fact] - public void AssemblyThrowingOperationPreservesTheUnavailableFailure() - { - UnavailableAssemblyClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.Disassemble(_address, TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void RemoteExecutionTryOperationsReturnCopiedDefaultsAndStableFailures() - { - UnavailableRemoteExecutionClient client = new(); - RemoteDllInjectionRequest injection = new(Path.GetFullPath("fixture.dll")); - RemoteCallRequest call = new(_address, [1, 2, 3], TimeSpan.FromMilliseconds(1)); - - Assert.False(client.TryInjectLibrary(injection, out CheatEngineFailure injectFailure, - TestContext.Current.CancellationToken)); - Assert.Equal("RemoteExecution.InjectLibrary", injectFailure.Operation); - - Assert.False(client.TryInvoke(call, out RemoteCallResult result, out CheatEngineFailure callFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(default, result); - Assert.Equal("RemoteExecution.Invoke", callFailure.Operation); - } - - [Fact] - public void RemoteExecutionThrowingOperationPreservesTheUnavailableFailure() - { - UnavailableRemoteExecutionClient client = new(); - RemoteDllInjectionRequest request = new(Path.GetFullPath("fixture.dll")); - - CheatEngineOperationException exception = Assert.Throws(() => - { - client.InjectLibrary(request, TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void SpeedTryOperationsReturnDefaultsAndStableFailures() - { - UnavailableSpeedClient client = new(); - - Assert.False(client.TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure getFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(default, multiplier); - Assert.Equal("Speed.GetMultiplier", getFailure.Operation); - - Assert.False(client.TrySetMultiplier(new SpeedMultiplier(1), out CheatEngineFailure setFailure, - TestContext.Current.CancellationToken)); - Assert.Equal("Speed.SetMultiplier", setFailure.Operation); - } - - [Fact] - public void SpeedThrowingOperationPreservesTheUnavailableFailure() - { - UnavailableSpeedClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.GetMultiplier(TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void HashingKeepsMemoryAndFileFailuresAsSeparateOperations() - { - UnavailableHashingClient client = new(); - MemoryHashRequest memory = new(_address, 4); - FileHashRequest file = new(Path.GetFullPath("fixture.bin")); - - Assert.False(client.TryHashMemory(memory, out HashDigest memoryDigest, out CheatEngineFailure memoryFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(default, memoryDigest); - Assert.Equal("Hashing.HashMemory", memoryFailure.Operation); - - Assert.False(client.TryHashFile(file, out HashDigest fileDigest, out CheatEngineFailure fileFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(default, fileDigest); - Assert.Equal("Hashing.HashFile", fileFailure.Operation); - } - - [Fact] - public void CancellationTakesPrecedenceOverTheCapabilityGate() - { - UnavailableHashingClient client = new(); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - bool succeeded = client.TryHashMemory(new MemoryHashRequest(_address, 1), out _, out CheatEngineFailure failure, - cancellation.Token); - - Assert.False(succeeded); - Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); - Assert.Equal("Hashing.HashMemory", failure.Operation); - } - - [Fact] - public void DebuggerTryRegistrationReturnsNoLeaseAndTheLiveGateFailure() - { - UnavailableDebuggerClient client = new(); - - bool succeeded = client.TryRegisterBreakpoint(new BreakpointRequest(_address), - static _ => BreakpointDisposition.Continue, - new EventStreamOptions(1), out IBreakpointLease? lease, out CheatEngineFailure failure, - TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Null(lease); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Debugger.RegisterBreakpoint", failure.Operation); - } - - [Fact] - public void DebuggerThrowingRegistrationPreservesTheUnavailableFailure() - { - UnavailableDebuggerClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.RegisterBreakpoint(new BreakpointRequest(_address), static _ => BreakpointDisposition.Continue, - new EventStreamOptions(1), TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void DebuggerRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() - { - UnavailableDebuggerClient client = new(); - BreakpointRequest request = new(_address); - - Assert.Throws(() => client.TryRegisterBreakpoint(request, - static _ => BreakpointDisposition.Continue, default, out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => client.TryRegisterBreakpoint(request, null!, - new EventStreamOptions(1), - out _, out _, TestContext.Current.CancellationToken)); - } - - [Fact] - public void HotkeyTryRegistrationReturnsNoLeaseAndTheLiveGateFailure() - { - UnavailableHotkeyClient client = new(); - HotkeyRegistration registration = new("fixture", new HotkeyGesture(0x41)); - - bool succeeded = client.TryRegister(registration, static _ => - { - }, new EventStreamOptions(1), - out IHotkeyLease? lease, out CheatEngineFailure failure, TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Null(lease); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Hotkeys.Register", failure.Operation); - } - - [Fact] - public void HotkeyThrowingRegistrationPreservesTheUnavailableFailure() - { - UnavailableHotkeyClient client = new(); - HotkeyRegistration registration = new("fixture", new HotkeyGesture(0x41)); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.Register(registration, static _ => - { - }, new EventStreamOptions(1), TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void HotkeyRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() - { - UnavailableHotkeyClient client = new(); - HotkeyRegistration registration = new("fixture", new HotkeyGesture(0x41)); - - Assert.Throws(() => client.TryRegister(registration, static _ => - { - }, default, - out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => client.TryRegister(registration, null!, new EventStreamOptions(1), - out _, out _, TestContext.Current.CancellationToken)); - } - - [Fact] - public void TimerTryRegistrationReturnsNoLeaseAndTheLiveGateFailure() - { - UnavailableTimerClient client = new(); - - bool succeeded = client.TryRegister(new TimerRequest(TimeSpan.FromMilliseconds(1)), static _ => - { - }, - new EventStreamOptions(1), out ITimerLease? lease, out CheatEngineFailure failure, - TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Null(lease); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Timers.Register", failure.Operation); - } - - [Fact] - public void TimerThrowingRegistrationPreservesTheUnavailableFailure() - { - UnavailableTimerClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.Register(new TimerRequest(TimeSpan.FromMilliseconds(1)), static _ => - { - }, - new EventStreamOptions(1), TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void TimerRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() - { - UnavailableTimerClient client = new(); - TimerRequest request = new(TimeSpan.FromMilliseconds(1)); - - Assert.Throws(() => client.TryRegister(request, static _ => - { - }, default, - out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => client.TryRegister(request, null!, new EventStreamOptions(1), - out _, out _, TestContext.Current.CancellationToken)); - } - - [Fact] - public void DbvmObservesWithoutInitializingAndKeepsWatchRegistrationGated() - { - UnavailableDbvmClient client = new(); - - Assert.False(client.TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure statusFailure, - TestContext.Current.CancellationToken)); - Assert.Equal(default, status); - Assert.Equal("Dbvm.GetStatus", statusFailure.Operation); - - Assert.False(client.TryInitialize(new DbvmInitializationRequest(), out DbvmStatusSnapshot initialized, - out CheatEngineFailure initializeFailure, TestContext.Current.CancellationToken)); - Assert.Equal(default, initialized); - Assert.Equal("Dbvm.Initialize", initializeFailure.Operation); - - Assert.False(client.TryRegisterWatch(new DbvmWatchRequest(_address, 1), static _ => - { - }, new EventStreamOptions(1), - out IDbvmWatchLease? lease, out CheatEngineFailure watchFailure, TestContext.Current.CancellationToken)); - Assert.Null(lease); - Assert.Equal("Dbvm.RegisterWatch", watchFailure.Operation); - } - - [Fact] - public void DbvmThrowingObservationPreservesTheUnavailableFailure() - { - UnavailableDbvmClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.GetStatus(TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } - - [Fact] - public void DbvmWatchRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() - { - UnavailableDbvmClient client = new(); - DbvmWatchRequest request = new(_address, 1); - - Assert.Throws(() => client.TryRegisterWatch(request, static _ => - { - }, default, - out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => client.TryRegisterWatch(request, null!, new EventStreamOptions(1), - out _, out _, TestContext.Current.CancellationToken)); - } - - [Fact] - public void DbvmThrowingWatchRegistrationPreservesTheUnavailableFailure() - { - UnavailableDbvmClient client = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = client.RegisterWatch(new DbvmWatchRequest(_address, 1), static _ => - { - }, new EventStreamOptions(1), - TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs deleted file mode 100644 index f726c38..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CheatEngine.Client.Core.Domains; -using CheatEngine.Client.Results; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class UnavailableValueScannerExceptionCoverageTests -{ - [Fact] - public void CreateSessionThrowsTheCancellationFailureWhenCancellationPrecedesTheCapabilityGate() - { - UnavailableValueScanner scanner = new(); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = scanner.CreateSession(cancellation.Token); - }); - - Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); - Assert.Equal("Scans.CreateSession", exception.Failure.Operation); - Assert.Equal("The operation was cancelled before Cheat Engine work began.", exception.Failure.Message); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs deleted file mode 100644 index 731254b..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using CheatEngine.Client.Core.Domains; -using CheatEngine.Client.Core.Infrastructure; -using CheatEngine.Client.Core.Tests.TestSupport; -using CheatEngine.Client.Results; -using CheatEngine.Client.Scanning; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class UnavailableValueScannerTests -{ - [Fact] - public void TryCreateSessionReportsCapabilityUnavailableBeforeTheLiveOwnershipGate() - { - UnavailableValueScanner scanner = new(); - - bool succeeded = scanner.TryCreateSession( - out IValueScanSession? session, out CheatEngineFailure failure, TestContext.Current.CancellationToken); - - Assert.False(succeeded); - Assert.Null(session); - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal((string?) "Scans.CreateSession", (string?) failure.Operation); - Assert.Contains("no public ownership factory", failure.Message, StringComparison.Ordinal); - Assert.Contains("cannot return CEObject", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void TryCreateSessionReportsCancellationWithoutAttemptingHostWork() - { - UnavailableValueScanner scanner = new(); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - bool succeeded = scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, - cancellation.Token); - - Assert.False(succeeded); - Assert.Null(session); - Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); - } - - [Fact] - public void TryCreateSessionRejectsAStaleActivationBeforeCapabilityOrCancellation() - { - using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; - using CoreLifetime lifetime = new(context); - UnavailableValueScanner scanner = new(lifetime); - using CancellationTokenSource cancellation = new(); - cancellation.Cancel(); - - CheatEngineActivationExpiredException exception = Assert.Throws(() => - scanner.TryCreateSession(out _, out _, cancellation.Token)); - - Assert.Equal("Scans.CreateSession", exception.Failure.Operation); - } - - [Fact] - public void CreateSessionThrowsTheClassifiedUnavailableFailure() - { - UnavailableValueScanner scanner = new(); - - CheatEngineOperationException exception = Assert.Throws(() => - { - _ = scanner.CreateSession(TestContext.Current.CancellationToken); - }); - - Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs deleted file mode 100644 index 812faf9..0000000 --- a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using CheatEngine.Client.Core.Domains; -using CheatEngine.Client.Scanning; - -namespace CheatEngine.Client.Core.Tests.Domains; - -public sealed class ValueScanSessionStateMachineTests -{ - [Fact] - public void FirstAndNextScansFollowTheManagedHappyPath() - { - ValueScanSessionStateMachine state = new(); - - state.BeginFirstScan(); - Assert.Equal(ValueScanSessionState.Scanning, state.State); - - state.CompleteScan(); - Assert.Equal(ValueScanSessionState.ResultsReady, state.State); - - state.BeginNextScan(); - Assert.Equal(ValueScanSessionState.Scanning, state.State); - - state.CompleteScan(); - Assert.Equal(ValueScanSessionState.ResultsReady, state.State); - } - - [Fact] - public void FailedStartedOperationInvalidatesUntilReset() - { - ValueScanSessionStateMachine state = new(); - state.BeginFirstScan(); - - state.Invalidate(); - - Assert.Equal(ValueScanSessionState.Invalidated, state.State); - Assert.Throws(state.BeginFirstScan); - Assert.Throws(state.BeginNextScan); - - state.Reset(); - Assert.Equal(ValueScanSessionState.Created, state.State); - } - - [Fact] - public void ResetDoesNotInterruptScanning() - { - ValueScanSessionStateMachine state = new(); - state.BeginFirstScan(); - - Assert.Throws(state.Reset); - Assert.Equal(ValueScanSessionState.Scanning, state.State); - } - - [Fact] - public void DisposalIsIdempotentAndTerminal() - { - ValueScanSessionStateMachine state = new(); - - state.MarkDisposed(); - state.MarkDisposed(); - - Assert.Equal(ValueScanSessionState.Disposed, state.State); - Assert.Throws(state.Reset); - Assert.Throws(state.BeginFirstScan); - } -} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScanMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScanMappingTests.cs new file mode 100644 index 0000000..4cd5baf --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScanMappingTests.cs @@ -0,0 +1,291 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.ValueScanning; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Tests.Domains.ValueScanning; + +/// +/// Every value-scan outcome of the consumed CheatEngine.SDK maps to its dedicated Client value, and a value the SDK +/// could add fails closed (plan L15, Q48). +/// +public sealed class ValueScanMappingTests +{ + private const string Operation = "ValueScans.Contract"; + + private static Dictionary + ExpectedCreationFailures => new() + { + [MemoryScanCreationStatus.TargetIdentityUnavailable] = + (CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.NotStarted), + [MemoryScanCreationStatus.GlobalUnavailable] = + (CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotApplied), + [MemoryScanCreationStatus.LuaFailure] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Unknown), + [MemoryScanCreationStatus.NoScannerResult] = + (CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.NotApplied), + [MemoryScanCreationStatus.NoFoundListResult] = + (CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.NotApplied), + [MemoryScanCreationStatus.InvalidScannerResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.NotApplied), + [MemoryScanCreationStatus.InvalidFoundListResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.NotApplied), + [MemoryScanCreationStatus.AliasedFoundList] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.NotApplied), + [MemoryScanCreationStatus.RollbackUnconfirmed] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed), + // A success without a session breaks the port contract, and Unknown is never produced by a completed factory: + // like an unrecognized status, neither proves that no scanner remains. + [MemoryScanCreationStatus.Success] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed), + [MemoryScanCreationStatus.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed) + }; + + private static Dictionary + ExpectedPageFailures => new() + { + [MemoryScanMaterializationStatus.DestinationTooSmall] = + (CheatEngineFailureKind.ResultLimitExceeded, CheatEngineHostEffect.Completed), + [MemoryScanMaterializationStatus.Cancelled] = + (CheatEngineFailureKind.Cancelled, CheatEngineHostEffect.Completed), + [MemoryScanMaterializationStatus.RuntimeInvalidated] = + (CheatEngineFailureKind.RuntimeChanged, CheatEngineHostEffect.NotStarted), + [MemoryScanMaterializationStatus.TargetIdentityUnavailable] = + (CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.NotStarted), + [MemoryScanMaterializationStatus.TargetIdentityMismatch] = + (CheatEngineFailureKind.TargetChanged, CheatEngineHostEffect.NotStarted), + [MemoryScanMaterializationStatus.LuaFailure] = (CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Unknown), + [MemoryScanMaterializationStatus.InvalidResult] = + (CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + [MemoryScanMaterializationStatus.PageStartOutOfRange] = + (CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.Completed), + [MemoryScanMaterializationStatus.Unknown] = + (CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown) + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryCreationStatusMapsToItsFailure() + { + CheatEngineFailure fallback = ValueScanMapping.FromCreationStatus( + MappingTotality.Undefined(), Operation); + + MappingTotality.AssertTotal( + static status => ExpectedCreationFailures.TryGetValue(status, + out (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) expected) && + Describe(ValueScanMapping.FromCreationStatus(status, Operation)) == expected, + status => ValueScanMapping.FromCreationStatus(status, Operation) == fallback); + Assert.Equal((CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.CleanupUnconfirmed), + Describe(fallback)); + Assert.Equal(Enum.GetValues().Order(), ExpectedCreationFailures.Keys.Order()); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EverySessionStateMapsToAClientState() + { + MappingTotality.AssertTotal( + static state => ValueScanMapping.ToState(state) != ValueScanSessionState.Unknown, + static state => ValueScanMapping.ToState(state) == ValueScanSessionState.Unknown); + Assert.Equal(ValueScanSessionState.Created, ValueScanMapping.ToState(MemoryScanState.New)); + Assert.Equal(ValueScanSessionState.Closed, ValueScanMapping.ToState(MemoryScanState.Disposed)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryInvalidationReasonMapsToAClientKind() + { + Dictionary expected = new() + { + [MemoryScanInvalidationReason.None] = ValueScanInvalidationKind.None, + [MemoryScanInvalidationReason.ProtectedLuaFailure] = ValueScanInvalidationKind.HostCallFailed, + [MemoryScanInvalidationReason.RuntimeIdentityChanged] = ValueScanInvalidationKind.RuntimeChanged, + [MemoryScanInvalidationReason.TargetChanged] = ValueScanInvalidationKind.TargetChanged, + [MemoryScanInvalidationReason.TargetProcessReused] = ValueScanInvalidationKind.TargetChanged, + // Only the experimental stop request, which the Client never makes, produces it. + [MemoryScanInvalidationReason.ScanTerminated] = ValueScanInvalidationKind.Unknown + }; + + MappingTotality.AssertTotal( + reason => expected.TryGetValue(reason, out ValueScanInvalidationKind kind) && + ValueScanMapping.ToInvalidation(reason) == kind, + static reason => ValueScanMapping.ToInvalidation(reason) == ValueScanInvalidationKind.Unknown); + Assert.Equal(Enum.GetValues().Order(), expected.Keys.Order()); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryCancellationMilestoneMapsToACancellationEffect() + { + Dictionary expected = new() + { + [MemoryScanCancellationMilestone.None] = CheatEngineHostEffect.Unknown, + [MemoryScanCancellationMilestone.CancelledBeforeNativeCall] = CheatEngineHostEffect.NotStarted, + [MemoryScanCancellationMilestone.ObservedAfterNativeCall] = CheatEngineHostEffect.Completed + }; + CheatEngineFailure fallback = ValueScanMapping.Cancelled(Operation, + MappingTotality.Undefined()); + + MappingTotality.AssertTotal( + milestone => expected.TryGetValue(milestone, out CheatEngineHostEffect effect) && + ValueScanMapping.Cancelled(Operation, milestone) is + { + Kind: CheatEngineFailureKind.Cancelled + } failure && + failure.HostEffect == effect && failure != fallback, + milestone => ValueScanMapping.Cancelled(Operation, milestone) == fallback); + Assert.Equal(CheatEngineHostEffect.Unknown, fallback.HostEffect); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMaterializationStatusIsAPageOrItsFailure() + { + CheatEngineFailure fallback = PageFailure(MappingTotality.Undefined()); + + MappingTotality.AssertTotal( + static status => status is MemoryScanMaterializationStatus.Success or MemoryScanMaterializationStatus.NoResults + ? !ValueScanMapping.TryGetPageFailure(status, MemoryScanCancellationMilestone.None, Operation, out _) + : ExpectedPageFailures.TryGetValue(status, + out (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) expected) && + Describe(PageFailure(status)) == expected, + status => PageFailure(status) == fallback); + Assert.Equal((CheatEngineFailureKind.IndeterminateHostResult, CheatEngineHostEffect.Unknown), Describe(fallback)); + Assert.Equal( + Enum.GetValues().Except( + [MemoryScanMaterializationStatus.Success, MemoryScanMaterializationStatus.NoResults]).Order(), + ExpectedPageFailures.Keys.Order()); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMemoryScanFailureKindTellsHowFarAScanGot() + { + // The SDK checks the runtime and the target before any Cheat Engine call of an operation; every other failure + // category comes from a Cheat Engine call that began. + Dictionary expected = new() + { + [MemoryScanFailureKind.MissingCapability] = CheatEngineHostEffect.Started, + [MemoryScanFailureKind.LuaError] = CheatEngineHostEffect.Started, + [MemoryScanFailureKind.UnexpectedResult] = CheatEngineHostEffect.Started, + [MemoryScanFailureKind.RuntimeInvalidated] = CheatEngineHostEffect.NotStarted, + [MemoryScanFailureKind.TargetIdentityUnavailable] = CheatEngineHostEffect.NotStarted, + [MemoryScanFailureKind.TargetIdentityMismatch] = CheatEngineHostEffect.NotStarted + }; + + MappingTotality.AssertTotal( + kind => expected.TryGetValue(kind, out CheatEngineHostEffect effect) && + ValueScanMapping.MutationFaultEffect(kind) == effect, + static kind => ValueScanMapping.MutationFaultEffect(kind) == CheatEngineHostEffect.Unknown); + Assert.Equal(Enum.GetValues().Order(), expected.Keys.Order()); + Assert.Equal(CheatEngineHostEffect.NotStarted, ValueScanMapping.MutationFaultEffect(ScanFaults.State())); + Assert.Equal(CheatEngineHostEffect.Unknown, + ValueScanMapping.MutationFaultEffect(new ObjectDisposedException("MemoryScanSession"))); + Assert.Equal(CheatEngineHostEffect.Completed, + ValueScanMapping.ReadFaultEffect(ScanFaults.Scan(MemoryScanFailureKind.UnexpectedResult))); + Assert.Equal(CheatEngineHostEffect.NotStarted, + ValueScanMapping.ReadFaultEffect(ScanFaults.Scan(MemoryScanFailureKind.RuntimeInvalidated))); + Assert.Equal(CheatEngineHostEffect.Unknown, + ValueScanMapping.ReadFaultEffect(ScanFaults.Scan(MemoryScanFailureKind.LuaError))); + } + + [Theory] + [InlineData(TargetReleaseStatus.Released, TargetReleaseStatus.Released, MemoryScanTerminationStatus.NotRequired, + LeaseReleaseKind.Released, CheatEngineHostEffect.Completed)] + [InlineData(TargetReleaseStatus.Released, TargetReleaseStatus.Released, MemoryScanTerminationStatus.Confirmed, + LeaseReleaseKind.Released, CheatEngineHostEffect.Completed)] + [InlineData(TargetReleaseStatus.Released, TargetReleaseStatus.Released, MemoryScanTerminationStatus.WaitTimedOut, + LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started)] + [InlineData(TargetReleaseStatus.Released, TargetReleaseStatus.UnconfirmedAfterInvocation, + MemoryScanTerminationStatus.NotRequired, LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started)] + [InlineData(TargetReleaseStatus.RefusedProcessReused, TargetReleaseStatus.RefusedTargetChanged, + MemoryScanTerminationStatus.NotInvoked, LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted)] + [InlineData(TargetReleaseStatus.NotInvoked, TargetReleaseStatus.NotInvoked, MemoryScanTerminationStatus.NotInvoked, + LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted)] + [InlineData(TargetReleaseStatus.Unspecified, TargetReleaseStatus.Unspecified, MemoryScanTerminationStatus.Unknown, + LeaseReleaseKind.Unknown, CheatEngineHostEffect.NotStarted)] + public void TheReleaseKeepsTheWorseOfTheFoundListScannerAndStopOutcomes(TargetReleaseStatus foundList, + TargetReleaseStatus memScan, MemoryScanTerminationStatus termination, LeaseReleaseKind kind, + CheatEngineHostEffect hostEffect) + { + LeaseReleaseOutcome outcome = + ValueScanMapping.FromRelease(new ValueScanReleaseStatuses(foundList, memScan, termination)); + + Assert.Equal(new LeaseReleaseOutcome(kind, hostEffect), outcome); + Assert.Equal(outcome, + ValueScanMapping.FromRelease(new ValueScanReleaseStatuses(memScan, foundList, termination))); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryTerminationStatusTellsWhetherTheScanIsStopped() + { + Dictionary expected = new() + { + [MemoryScanTerminationStatus.Unknown] = false, + [MemoryScanTerminationStatus.NotRequired] = true, + [MemoryScanTerminationStatus.Confirmed] = true, + [MemoryScanTerminationStatus.WaitTimedOut] = false, + [MemoryScanTerminationStatus.TerminateFailed] = false, + [MemoryScanTerminationStatus.WaitFailed] = false, + [MemoryScanTerminationStatus.NotInvoked] = false + }; + + MappingTotality.AssertTotal( + termination => expected.TryGetValue(termination, out bool stopped) && + ScanTermination.IsStopConfirmed(termination) == stopped, + static termination => !ScanTermination.IsStopConfirmed(termination)); + Assert.Equal(Enum.GetValues().Order(), expected.Keys.Order()); + } + + [Theory] + [InlineData(LeaseReleaseKind.RefusedTargetChanged, CheatEngineFailureKind.TargetChanged)] + [InlineData(LeaseReleaseKind.RefusedTargetIdentityUnavailable, CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(LeaseReleaseKind.Released, CheatEngineFailureKind.InvalidState)] + [InlineData(LeaseReleaseKind.CleanupUnavailable, CheatEngineFailureKind.InvalidState)] + public void AReleasedSessionRefusesWithTheReasonOfItsRelease(LeaseReleaseKind releaseKind, + CheatEngineFailureKind kind) + { + CheatEngineFailure failure = ValueScanMapping.Released(Operation, + new LeaseReleaseOutcome(releaseKind, CheatEngineHostEffect.NotStarted)); + + Assert.Equal(kind, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(CheatEngineFailureKind.InvalidState, ValueScanMapping.Released(Operation, null).Kind); + } + + [Fact] + public void TheHostErrorTextIsAppendedOnlyWhenCheatEngineReportedOne() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.LuaError, Operation, "The scan failed."); + + Assert.Equal(failure, ValueScanMapping.WithHostErrorText(failure, null, false)); + Assert.Equal(failure, ValueScanMapping.WithHostErrorText(failure, " ", true)); + Assert.Equal("The scan failed. Cheat Engine reported: Nothing found", + ValueScanMapping.WithHostErrorText(failure, " Nothing found ", false).Message); + } + + [Fact] + public void TheDocumentedPageLimitIsTheCoreLimit() + { + // ValueScanReadRequest's documentation states the limit; keep both in step. + Assert.Equal(1024, ScanResourceLimits.MaximumValueScanPage); + } + + private static CheatEngineFailure PageFailure(MemoryScanMaterializationStatus status) + { + _ = ValueScanMapping.TryGetPageFailure(status, MemoryScanCancellationMilestone.ObservedAfterNativeCall, + Operation, out CheatEngineFailure failure); + return failure; + } + + private static (CheatEngineFailureKind Kind, CheatEngineHostEffect Effect) Describe(CheatEngineFailure failure) + { + return (failure.Kind, failure.HostEffect); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScanRequestsTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScanRequestsTests.cs new file mode 100644 index 0000000..c74e34f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScanRequestsTests.cs @@ -0,0 +1,187 @@ +using CheatEngine.Client.Core.Domains.ValueScanning; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains.ValueScanning; + +/// +/// The Client value-scan requests become CheatEngine.SDK's positional requests, or throw before dispatch. +/// +public sealed class ValueScanRequestsTests +{ + private const string Operation = "ValueScans.Contract"; + + [Fact] + public void AnExactFirstScanUsesCheatEngineDefaultsOverTheWholeAddressSpace() + { + FirstScanRequest request = + ValueScanRequests.CreateFirst(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(100))); + + Assert.Equal(ScanOption.ExactValue, request.ScanOption); + Assert.Equal(VariableType.Dword, request.VariableType); + Assert.Equal(RoundingType.Rounded, request.RoundingType); + Assert.Equal("100", request.Input1); + Assert.Equal(string.Empty, request.Input2); + Assert.Equal(Address.Zero, request.StartAddress); + Assert.Equal(new Address(ulong.MaxValue), request.StopAddress); + Assert.Equal(string.Empty, request.ProtectionFlags); + Assert.Equal(FastScanMethod.NotAligned, request.FastScanMethod); + Assert.Equal(string.Empty, request.AlignmentParameter); + Assert.False(request.IsHexadecimalInput); + Assert.False(request.IsNotBinaryString); + Assert.False(request.IsUnicodeScan); + Assert.False(request.IsCaseSensitive); + } + + [Fact] + public void TheRangeProtectionAndAlignmentBecomeCheatEngineArguments() + { + ValueScanFirstRequest client = ValueScanFirstRequest + .Between(ValueScanValue.FromDouble(1.5, 1), ValueScanValue.FromDouble(2.5, 1)) + .WithRange(new Address(0x1000), new Address(0x2000)) + .WithProtection(new ScanProtectionFilter(ScanProtectionRequirement.Any, ScanProtectionRequirement.Excluded, + ScanProtectionRequirement.Required)) + .WithAlignment(ScanAlignment.AlignedTo(8)); + + FirstScanRequest request = ValueScanRequests.CreateFirst(client); + FirstScanRequest lastDigits = + ValueScanRequests.CreateFirst(client.WithAlignment(ScanAlignment.LastDigits("0c"))); + + Assert.Equal(ScanOption.ValueBetween, request.ScanOption); + Assert.Equal(VariableType.Double, request.VariableType); + Assert.Equal("1.5", request.Input1); + Assert.Equal("2.5", request.Input2); + Assert.Equal(new Address(0x1000), request.StartAddress); + Assert.Equal(new Address(0x2000), request.StopAddress); + Assert.Equal("*X-C+W", request.ProtectionFlags); + Assert.Equal(FastScanMethod.Aligned, request.FastScanMethod); + Assert.Equal("8", request.AlignmentParameter); + Assert.Equal(FastScanMethod.LastDigits, lastDigits.FastScanMethod); + Assert.Equal("0C", lastDigits.AlignmentParameter); + FirstScanRequest none = ValueScanRequests.CreateFirst(client.WithAlignment(ScanAlignment.None)); + Assert.Equal(FastScanMethod.NotAligned, none.FastScanMethod); + Assert.Equal(string.Empty, none.AlignmentParameter); + } + + [Theory] + [InlineData(ValueScanValueType.Integer8, VariableType.Byte, false, false, false)] + [InlineData(ValueScanValueType.Integer16, VariableType.Word, false, false, false)] + [InlineData(ValueScanValueType.Integer32, VariableType.Dword, false, false, false)] + [InlineData(ValueScanValueType.Integer64, VariableType.Qword, false, false, false)] + [InlineData(ValueScanValueType.SingleFloat, VariableType.Single, false, false, false)] + [InlineData(ValueScanValueType.DoubleFloat, VariableType.Double, false, false, false)] + [InlineData(ValueScanValueType.Utf8String, VariableType.String, false, false, true)] + [InlineData(ValueScanValueType.Utf16String, VariableType.String, false, true, true)] + [InlineData(ValueScanValueType.ByteArray, VariableType.ByteArray, true, false, false)] + public void EveryValueTypeHasItsCheatEngineTypeAndInputFlags(ValueScanValueType valueType, + VariableType variableType, bool hexadecimal, bool unicode, bool caseSensitive) + { + Assert.Equal(new ValueScanRequests.ScanValueFlags(variableType, hexadecimal, unicode, caseSensitive), + ValueScanRequests.GetFlags(valueType)); + } + + [Fact] + public void EveryDefinedValueTypeAndComparisonMapsAndAnUndefinedOneIsRefused() + { + Assert.All(Enum.GetValues(), + static valueType => Assert.True(Enum.IsDefined(ValueScanRequests.GetFlags(valueType).VariableType))); + Assert.All(Enum.GetValues(), + static comparison => Assert.True(Enum.IsDefined(ValueScanRequests.ToScanOption(comparison)))); + Assert.Equal(Enum.GetValues().Order(), + Enum.GetValues().Select(ValueScanRequests.ToScanOption).Order()); + Assert.Throws(() => ValueScanRequests.GetFlags((ValueScanValueType) 99)); + Assert.Throws(() => ValueScanRequests.ToScanOption((ValueScanComparison) 99)); + } + + /// + /// A default request is a programming error: it throws, whatever the session, and builds no SDK request. + /// + [Fact] + public void ADefaultRequestThrowsWithoutACheatEngineCall() + { + ArgumentException first = Assert.Throws(() => ValueScanRequests.CreateFirst(default)); + ArgumentException next = Assert.Throws(() => ValueScanRequests.ValidateNext(default)); + + Assert.Equal("request", first.ParamName); + Assert.Equal("request", next.ParamName); + } + + /// + /// A next scan whose bounds were tampered with to have two types throws before the session is asked. + /// + [Fact] + public void ANextScanRangeOfTwoTypesThrows() + { + ValueScanNextRequest tampered = TamperedValues.WithBackingField( + ValueScanNextRequest.Between(ValueScanValue.FromInt32(1), ValueScanValue.FromInt32(2)), + nameof(ValueScanNextRequest.UpperValue), (ValueScanValue?) ValueScanValue.FromInt16(2)); + + ArgumentException thrown = Assert.Throws(() => ValueScanRequests.ValidateNext(tampered)); + + Assert.Equal("request", thrown.ParamName); + } + + /// + /// A value whose type no factory creates is tampered: a first or a next scan throws for it before the + /// comparison rules, which would report it as another type than the request's or the session's. + /// + [Theory] + [InlineData("First.Value")] + [InlineData("First.UpperValue")] + [InlineData("Next.Value")] + [InlineData("Next.UpperValue")] + public void AValueOfAnUndefinedTypeThrows(string tampered) + { + const ValueScanValueType undefinedType = (ValueScanValueType) 99; + ValueScanValue? undefined = TamperedValues.WithBackingField(ValueScanValue.FromInt32(2), + nameof(ValueScanValue.ValueType), undefinedType); + ValueScanFirstRequest firstRange = + ValueScanFirstRequest.Between(ValueScanValue.FromInt32(1), ValueScanValue.FromInt32(2)); + ValueScanNextRequest nextRange = + ValueScanNextRequest.Between(ValueScanValue.FromInt32(1), ValueScanValue.FromInt32(2)); + Action validate = tampered switch + { + "First.Value" => () => _ = ValueScanRequests.CreateFirst(TamperedValues.WithBackingField( + ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), nameof(ValueScanFirstRequest.Value), + undefined)), + "First.UpperValue" => () => _ = ValueScanRequests.CreateFirst(TamperedValues.WithBackingField(firstRange, + nameof(ValueScanFirstRequest.UpperValue), undefined)), + "Next.Value" => () => ValueScanRequests.ValidateNext(TamperedValues.WithBackingField( + ValueScanNextRequest.Exact(ValueScanValue.FromInt32(1)), nameof(ValueScanNextRequest.Value), + undefined)), + "Next.UpperValue" => () => ValueScanRequests.ValidateNext(TamperedValues.WithBackingField(nextRange, + nameof(ValueScanNextRequest.UpperValue), undefined)), + _ => throw new ArgumentOutOfRangeException(nameof(tampered), tampered, null) + }; + + ArgumentOutOfRangeException thrown = Assert.Throws(validate); + + Assert.Equal("request", thrown.ParamName); + Assert.Equal(undefinedType, thrown.ActualValue); + } + + [Fact] + public void ANextScanValueMustHaveTheTypeOfTheFirstScan() + { + Assert.True(ValueScanRequests.TryCreateNext(ValueScanNextRequest.IncreasedBy(ValueScanValue.FromInt16(3)), + ValueScanValueType.Integer16, Operation, out NextScanRequest increasedBy, out _)); + Assert.True(ValueScanRequests.TryCreateNext(ValueScanNextRequest.Unchanged(), ValueScanValueType.ByteArray, + Operation, out NextScanRequest unchanged, out _)); + Assert.False(ValueScanRequests.TryCreateNext(ValueScanNextRequest.Exact(ValueScanValue.FromInt32(3)), + ValueScanValueType.Integer16, Operation, out _, out CheatEngineFailure mismatch)); + + Assert.Equal(ScanOption.IncreasedValueBy, increasedBy.ScanOption); + Assert.Equal("3", increasedBy.Input1); + Assert.False(increasedBy.IsPercentageScan); + Assert.Null(increasedBy.SavedResultName); + Assert.Equal(ScanOption.Unchanged, unchanged.ScanOption); + Assert.Equal(string.Empty, unchanged.Input1); + Assert.True(unchanged.IsHexadecimalInput); + Assert.Equal(CheatEngineFailureKind.OperationRejected, mismatch.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, mismatch.HostEffect); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScannerTests.cs new file mode 100644 index 0000000..c1dd78a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanning/ValueScannerTests.cs @@ -0,0 +1,836 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.ValueScanning; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains.ValueScanning; + +/// +/// The value-scan battery of the audit (chapter 13) against a scripted SDK session: zero and many results, invalid +/// results, a malformed count, a creation failure after the first object, Cheat Engine closing during the wait, +/// cancellation before and after the start, a changed target, an error completion, next scans, a stale owner after +/// an external reset, a release while busy, a release that cannot reach Cheat Engine, and a process selected in Cheat +/// Engine's own window that keeps the sessions created for it. +/// +public sealed class ValueScannerTests : IDisposable +{ + private readonly ControlledCoreLifetimeContext _context = new(); + private readonly SdkMainThreadDispatcher _dispatcher; + private readonly CoreLifetime _lifetime; + private readonly FakeValueScanPort _port = new(); + private readonly ProcessClient _processes; + private readonly ValueScanner _scanner; + private readonly FakeSelectedTarget _target = new(); + + public ValueScannerTests() + { + _lifetime = new CoreLifetime(_context); + _dispatcher = new SdkMainThreadDispatcher(_lifetime, new InlineMainThreadInvoker()); + _processes = FakeSelectedTarget.CreateProcessClient(_dispatcher, _target); + _scanner = new ValueScanner(_dispatcher, _processes, _port); + } + + private FakeValueScanSessionHandle Handle => _port.Session; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + public void Dispose() + { + _context.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void ACreatedSessionIsRegisteredAndReleasedBeforeTheActivationEnds() + { + IValueScanSession session = CreateSession(); + + Assert.Equal(ValueScanSessionState.Created, session.State); + Assert.Equal(ValueScanInvalidationKind.None, session.Invalidation); + Assert.False(session.IsReleased); + Assert.Null(session.LastReleaseOutcome); + Assert.Equal(0, Handle.Destroys); + + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + _lifetime.DrainOwnedResourcesForDisable(); + } + + Assert.Equal(1, Handle.Destroys); + Assert.True(session.IsReleased); + Assert.Equal(ValueScanSessionState.Closed, session.State); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + session.LastReleaseOutcome); + } + + [Theory] + [Trait("Qualification", "Q25")] + [InlineData(MemoryScanCreationStatus.TargetIdentityUnavailable, CheatEngineFailureKind.TargetIdentityUnavailable, + CheatEngineHostEffect.NotStarted)] + [InlineData(MemoryScanCreationStatus.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineHostEffect.NotApplied)] + [InlineData(MemoryScanCreationStatus.LuaFailure, CheatEngineFailureKind.LuaError, CheatEngineHostEffect.Unknown)] + [InlineData(MemoryScanCreationStatus.NoScannerResult, CheatEngineFailureKind.OperationRejected, + CheatEngineHostEffect.NotApplied)] + [InlineData(MemoryScanCreationStatus.NoFoundListResult, CheatEngineFailureKind.OperationRejected, + CheatEngineHostEffect.NotApplied)] + [InlineData(MemoryScanCreationStatus.InvalidFoundListResult, CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.NotApplied)] + [InlineData(MemoryScanCreationStatus.AliasedFoundList, CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.NotApplied)] + [InlineData(MemoryScanCreationStatus.RollbackUnconfirmed, CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.CleanupUnconfirmed)] + public void ARefusedCreationPublishesNoSessionAndKeepsTheSecondObjectRollbackFact( + MemoryScanCreationStatus status, CheatEngineFailureKind kind, CheatEngineHostEffect hostEffect) + { + _port.Status = status; + + bool created = _scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, Token); + + Assert.False(created); + Assert.Null(session); + Assert.Equal(kind, failure.Kind); + Assert.Equal(hostEffect, failure.HostEffect); + Assert.Equal(ValueScanner.CreateOperation, failure.Operation); + Assert.Equal(1, _port.Creations); + } + + [Theory] + [InlineData(TargetReleaseStatus.Released, CheatEngineHostEffect.Unknown)] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, CheatEngineHostEffect.CleanupUnconfirmed)] + public void ASessionNextToAFailedCreationIsReleasedAndAnIncompleteReleaseLeavesTheCleanupUnconfirmed( + TargetReleaseStatus release, CheatEngineHostEffect hostEffect) + { + _port.Status = MemoryScanCreationStatus.LuaFailure; + _port.PublishesSessionOnFailure = true; + Handle.OwnerReleases = (release, release); + + bool created = _scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, Token); + + Assert.False(created); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(hostEffect, failure.HostEffect); + Assert.Equal(1, Handle.Destroys); + } + + [Fact] + public void CancellationBeforeCreationReachesNoFactory() + { + bool created = _scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + new CancellationToken(true)); + + Assert.False(created); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, _port.Creations); + Assert.Throws(() => + _scanner.CreateSession(new CancellationToken(true))); + } + + [Fact] + public void CancellationObservedAfterCreationReleasesTheNewSessionAndPublishesNothing() + { + using CancellationTokenSource cancellation = new(); + _port.DuringCreate = cancellation.Cancel; + + bool created = _scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(created); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(1, Handle.Destroys); + } + + [Fact] + public void ACreationFaultIsTranslatedAndCheatEngineClosingDuringItExpiresTheActivation() + { + InvalidOperationException detached = new("The Cheat Engine plugin is not enabled."); + _port.Fault = detached; + + bool created = _scanner.TryCreateSession(out _, out CheatEngineFailure failure, Token); + _port.DuringCreate = () => _context.IsCurrent = false; + CheatEngineActivationExpiredException expired = Assert.Throws(() => + _scanner.TryCreateSession(out _, out _, Token)); + + Assert.False(created); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Same(detached, failure.Exception); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, expired.Failure.Kind); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void AScanWithoutResultsReadsAsAnEmptyPage() + { + IValueScanSession session = CreateSession(); + + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(100)), Token); + ValueScanPage page = session.Read(new ValueScanReadRequest(0, 10), Token); + + Assert.Equal(ValueScanSessionState.ResultsReady, session.State); + Assert.Equal(0UL, session.GetResultCount(Token)); + Assert.Equal(0UL, page.ResultCount); + Assert.True(page.Matches.IsEmpty); + Assert.False(page.HasMore); + Assert.Equal(["StartFirstScan", "Wait", "CopyPage", "ResultCount"], Handle.Calls); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void ManyResultsAreReadInPagesBoundedByTheClientLimit() + { + for (int index = 0; index < 3000; index++) + { + Handle.Results.Add(new MemoryScanResult(new Address(0x10000 + ((ulong) index * 4)), "100")); + } + + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(100)), Token); + + ValueScanPage first = session.Read(new ValueScanReadRequest(0, 5000), Token); + ValueScanPage last = session.Read(new ValueScanReadRequest(2990, 100), Token); + + Assert.Equal(3000UL, session.GetResultCount(Token)); + Assert.Equal(ScanResourceLimits.MaximumValueScanPage, first.Matches.Length); + Assert.Equal(3000UL, first.ResultCount); + Assert.True(first.HasMore); + Assert.Equal(first.Matches.Length, first.NextStartIndex); + Assert.Equal(new Address(0x10000), first.Matches[0].Address); + Assert.Equal("100", first.Matches[0].ValueText); + Assert.Equal(10, last.Matches.Length); + Assert.Equal(2990, last.StartIndex); + Assert.False(last.HasMore); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void AnInvalidResultOrAPageBeyondTheResultsPublishesNoPage() + { + Handle.Results.Add(new MemoryScanResult(new Address(0x1000), "1")); + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + + bool beyond = session.TryRead(new ValueScanReadRequest(5, 1), out ValueScanPage beyondPage, + out CheatEngineFailure beyondFailure, Token); + Handle.CopyStatus = MemoryScanMaterializationStatus.InvalidResult; + bool invalid = session.TryRead(new ValueScanReadRequest(0, 1), out ValueScanPage invalidPage, + out CheatEngineFailure invalidFailure, Token); + + Assert.False(beyond); + Assert.Equal(default, beyondPage); + Assert.Equal(CheatEngineFailureKind.OperationRejected, beyondFailure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, beyondFailure.HostEffect); + Assert.False(invalid); + Assert.Equal(default, invalidPage); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, invalidFailure.Kind); + Assert.Equal(ValueScanSession.ReadOperation, invalidFailure.Operation); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void AMalformedCountIsAnInvalidHostResult() + { + MemoryScanException malformed = ScanFaults.Scan(MemoryScanFailureKind.UnexpectedResult); + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + Handle.CountFault = malformed; + + bool counted = session.TryGetResultCount(out ulong count, out CheatEngineFailure failure, Token); + + Assert.False(counted); + Assert.Equal(0UL, count); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Same(malformed, failure.Exception); + Assert.Throws(() => session.GetResultCount(Token)); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void CheatEngineClosingDuringTheWaitExpiresTheActivation() + { + IValueScanSession session = CreateSession(); + Handle.WaitFault = new InvalidOperationException("The Cheat Engine plugin is not enabled."); + Handle.DuringWait = () => _context.IsCurrent = false; + + CheatEngineActivationExpiredException expired = Assert.Throws(() => + session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), out _, Token)); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, expired.Failure.Kind); + Assert.Equal(ValueScanSessionState.Invalidated, session.State); + } + + [Fact] + public void CancellationBeforeTheStartReachesNoScanAndLeavesTheSessionCreated() + { + IValueScanSession session = CreateSession(); + using CancellationTokenSource cancellation = new(); + Handle.BeforeStartCheck = cancellation.Cancel; + + bool precancelled = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure precancelledFailure, new CancellationToken(true)); + bool raced = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure racedFailure, cancellation.Token); + + Assert.False(precancelled); + Assert.Equal(CheatEngineFailureKind.Cancelled, precancelledFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, precancelledFailure.HostEffect); + Assert.False(raced); + Assert.Equal(CheatEngineFailureKind.Cancelled, racedFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, racedFailure.HostEffect); + Assert.Equal(["StartFirstScan"], Handle.Calls); + Assert.Equal(ValueScanSessionState.Created, session.State); + } + + [Fact] + public void CancellationAfterTheStartLeavesTheScanRunningUntilTheRelease() + { + IValueScanSession session = CreateSession(); + using CancellationTokenSource cancellation = new(); + Handle.DuringStart = cancellation.Cancel; + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, cancellation.Token); + bool reset = session.TryReset(out CheatEngineFailure resetFailure, Token); + LeaseReleaseOutcome released = session.Release(); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.False(reset); + Assert.Equal(CheatEngineFailureKind.InvalidState, resetFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, resetFailure.HostEffect); + // The release asked Cheat Engine to stop the scan that may still run, and Cheat Engine confirmed the stop. + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), released); + Assert.Equal(1, Handle.StopRequests); + Assert.Equal(1, Handle.Destroys); + } + + [Theory] + [InlineData(MemoryScanTerminationStatus.WaitTimedOut)] + [InlineData(MemoryScanTerminationStatus.TerminateFailed)] + [InlineData(MemoryScanTerminationStatus.WaitFailed)] + public void AStopOfARunningScanThatIsNotConfirmedLeavesTheReleaseUnconfirmed(MemoryScanTerminationStatus stop) + { + IValueScanSession session = CreateSession(); + using CancellationTokenSource cancellation = new(); + Handle.DuringStart = cancellation.Cancel; + Handle.StopStatus = stop; + _ = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), out _, cancellation.Token); + + LeaseReleaseOutcome released = session.Release(); + + // Both objects were destroyed, but a scan thread may still run: never reported as a confirmed release. + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + released); + Assert.True(released.RequiresManualRecovery); + Assert.True(session.IsReleased); + Assert.Equal(1, Handle.StopRequests); + } + + [Fact] + public void ACompletedScanNeedsNoStopWhenItIsReleased() + { + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + + LeaseReleaseOutcome released = session.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), released); + Assert.Equal(0, Handle.StopRequests); + } + + [Fact] + [Trait("Qualification", "Q25")] + public async Task AReleaseFromAWorkerThreadRunsOnCheatEngineMainThreadAsync() + { + using DedicatedThreadInvoker mainThread = new(); + SdkMainThreadDispatcher dispatcher = new(_lifetime, mainThread); + FakeValueScanPort port = new(); + IValueScanSession session = new ValueScanner(dispatcher, FakeSelectedTarget.CreateProcessClient(dispatcher), + port).CreateSession(Token); + int? releaseThread = null; + port.Session.OnRelease = () => releaseThread = Environment.CurrentManagedThreadId; + + LeaseReleaseOutcome released = await Task.Run(session.Release, Token); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), released); + Assert.Equal(mainThread.ThreadId, releaseThread); + Assert.Equal(1, port.Session.Destroys); + Assert.True(session.IsReleased); + } + + [Fact] + public void CancellationDuringTheWaitCompletesTheScanAndPublishesNothing() + { + IValueScanSession session = CreateSession(); + using CancellationTokenSource cancellation = new(); + Handle.DuringWait = cancellation.Cancel; + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, cancellation.Token); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(ValueScanSessionState.ResultsReady, session.State); + Assert.Throws(() => + session.NextScan(ValueScanNextRequest.Changed(), new CancellationToken(true))); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ATargetChangeRefusesTheScanAndReleasesTheSessionWithoutRetargeting() + { + IValueScanSession session = CreateSession(); + Handle.ContextFault = ScanFaults.Scan(MemoryScanFailureKind.TargetIdentityMismatch); + Handle.OwnerReleases = (TargetReleaseStatus.RefusedTargetChanged, TargetReleaseStatus.RefusedTargetChanged); + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, Token); + ValueScanInvalidationKind invalidation = session.Invalidation; + _ = _lifetime.TargetSelection.Advance("Processes.Attach"); + int callsAfterRelease = Handle.Calls.Count; + bool after = session.TryReset(out CheatEngineFailure afterFailure, Token); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.TargetChanged, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(ValueScanInvalidationKind.TargetChanged, invalidation); + Assert.True(session.IsReleased); + Assert.Equal(ValueScanSessionState.Closed, session.State); + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, session.LastReleaseOutcome?.Kind); + Assert.True(session.LastReleaseOutcome?.RequiresManualRecovery); + Assert.False(after); + Assert.Equal(CheatEngineFailureKind.TargetChanged, afterFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, afterFailure.HostEffect); + Assert.Equal(callsAfterRelease, Handle.Calls.Count); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void AnErrorCompletionCarriesCheatEngineTextAndAResetRecoversTheSession() + { + MemoryScanException luaError = ScanFaults.Scan(MemoryScanFailureKind.LuaError); + IValueScanSession session = CreateSession(); + Handle.WaitFault = luaError; + Handle.HostErrorText = "Invalid value"; + Handle.HostErrorTextTruncated = true; + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, Token); + ValueScanSessionState failedState = session.State; + ValueScanInvalidationKind invalidation = session.Invalidation; + Handle.WaitFault = null; + session.Reset(Token); + ValueScanSessionState resetState = session.State; + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.Same(luaError, failure.Exception); + Assert.EndsWith(" Cheat Engine reported: Invalid value (truncated)", failure.Message, StringComparison.Ordinal); + Assert.Equal(ValueScanSessionState.Invalidated, failedState); + Assert.Equal(ValueScanInvalidationKind.HostCallFailed, invalidation); + Assert.Equal(ValueScanSessionState.Created, resetState); + Assert.Equal(ValueScanSessionState.ResultsReady, session.State); + Assert.Equal(ValueScanInvalidationKind.None, session.Invalidation); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void NextScansCompareTheTypeOfTheFirstScan() + { + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.UnknownInitialValue(ValueScanValueType.Integer32), Token); + + session.NextScan(ValueScanNextRequest.Exact(ValueScanValue.FromInt32(95)), Token); + NextScanRequest exact = Handle.LastNextScan.GetValueOrDefault(); + bool mismatched = session.TryNextScan(ValueScanNextRequest.Exact(ValueScanValue.FromInt64(95)), + out CheatEngineFailure mismatch, Token); + int startsAfterMismatch = Handle.Calls.Count(static call => call == "StartNextScan"); + session.NextScan(ValueScanNextRequest.Decreased(), Token); + + Assert.Equal(ScanOption.UnknownValue, Handle.LastFirstScan.GetValueOrDefault().ScanOption); + Assert.Equal(VariableType.Dword, Handle.LastFirstScan.GetValueOrDefault().VariableType); + Assert.Equal(ScanOption.ExactValue, exact.ScanOption); + Assert.Equal("95", exact.Input1); + Assert.False(mismatched); + Assert.Equal(CheatEngineFailureKind.OperationRejected, mismatch.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, mismatch.HostEffect); + Assert.Equal(1, startsAfterMismatch); + Assert.Equal(ScanOption.DecreasedValue, Handle.LastNextScan.GetValueOrDefault().ScanOption); + Assert.Equal(ValueScanSessionState.ResultsReady, session.State); + } + + [Fact] + public void ANextScanBeforeAFirstScanIsRefusedByTheSessionState() + { + IValueScanSession session = CreateSession(); + + bool scanned = session.TryNextScan(ValueScanNextRequest.Changed(), out CheatEngineFailure failure, Token); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(ValueScanSessionState.Created, session.State); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void AStaleOwnerAfterAnExternalResetIsRefusedAndItsReleaseIsReportedAtDeactivation() + { + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + Handle.ContextFault = ScanFaults.Scan(MemoryScanFailureKind.RuntimeInvalidated); + // CheatEngine.SDK consumes the owners of a session from an earlier runtime without any Cheat Engine call. + Handle.OwnerReleases = (TargetReleaseStatus.NotInvoked, TargetReleaseStatus.NotInvoked); + + bool counted = session.TryGetResultCount(out _, out CheatEngineFailure countFailure, Token); + bool scanned = session.TryNextScan(ValueScanNextRequest.Changed(), out CheatEngineFailure scanFailure, Token); + LeaseReleaseOutcome released = session.Release(); + bool afterRelease = session.TryGetResultCount(out _, out CheatEngineFailure afterFailure, Token); + + Assert.False(counted); + Assert.Equal(CheatEngineFailureKind.RuntimeChanged, countFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, countFailure.HostEffect); + Assert.Equal(ValueScanInvalidationKind.RuntimeChanged, session.Invalidation); + // The invalidated session refuses a next scan by its state, before any Cheat Engine call. + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.InvalidState, scanFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, scanFailure.HostEffect); + // NotInvoked stays retryable: the lease stays registered and the deactivation report carries it. + Assert.Equal(LeaseReleaseKind.CleanupUnavailable, released.Kind); + Assert.False(session.IsReleased); + Assert.Equal(ValueScanSessionState.Closed, session.State); + Assert.False(afterRelease); + Assert.Equal(CheatEngineFailureKind.InvalidState, afterFailure.Kind); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void AReleaseRequestedDuringTheWaitRunsOnceAfterItAndTheSessionReportsItsFinalOutcome() + { + IValueScanSession session = CreateSession(); + LeaseReleaseOutcome? duringWait = null; + Handle.DuringWait = () => duringWait = session.Release(); + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, Token); + bool releasedAfterScan = session.IsReleased; + LeaseReleaseOutcome final = session.Release(); + + Assert.Equal(LeaseReleaseKind.Unknown, duringWait?.Kind); + Assert.True(duringWait?.IsRetryable); + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.IsType(failure.Exception); + Assert.False(releasedAfterScan); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), final); + Assert.True(session.IsReleased); + Assert.Equal(1, Handle.Destroys); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void ACallFromWorkCheatEngineRunsDuringTheWaitIsRefusedWithoutACheatEngineCall() + { + IValueScanSession session = CreateSession(); + CheatEngineFailure reentrant = default; + bool reentrantSucceeded = true; + Handle.DuringWait = () => + reentrantSucceeded = session.TryGetResultCount(out _, out reentrant, Token); + + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + + Assert.False(reentrantSucceeded); + Assert.Equal(CheatEngineFailureKind.InvalidState, reentrant.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, reentrant.HostEffect); + Assert.Equal(ValueScanSessionState.ResultsReady, session.State); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void AReleaseThatCannotReachCheatEngineKeepsTheLeaseForTheDeactivationReport() + { + IValueScanSession session = CreateSession(); + Handle.OwnerReleases = (TargetReleaseStatus.NotInvoked, TargetReleaseStatus.NotInvoked); + + _context.Stop(); + LeaseReleaseOutcome refused = session.Release(); + int destroysWhileStopping = Handle.Destroys; + AggregateException? report = null; + using (_lifetime.EnterCleanupScope()) + { + try + { + _lifetime.DrainOwnedResourcesForDisable(); + } + catch (CheatEngineOperationException single) + { + report = new AggregateException(single); + } + catch (AggregateException several) + { + report = several; + } + } + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + refused); + Assert.Equal(0, destroysWhileStopping); + Assert.Equal(1, Handle.Destroys); + Assert.NotNull(report); + CheatEngineOperationException reported = Assert.IsType( + Assert.Single(report.InnerExceptions)); + Assert.Equal(ValueScanSession.ReleaseOperation, reported.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, reported.Failure.Kind); + } + + [Fact] + public void DisposeNeverThrowsWhenTheReleaseFaults() + { + IValueScanSession session = CreateSession(); + Handle.ReleaseFault = new InvalidOperationException("destroy raised"); + + session.Dispose(); + + Assert.True(session.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + session.LastReleaseOutcome); + } + + [Fact] + public void AReleasedSessionRefusesEveryOperationWithoutACheatEngineCall() + { + IValueScanSession session = CreateSession(); + session.Dispose(); + int calls = Handle.Calls.Count; + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, Token); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(calls, Handle.Calls.Count); + Assert.Equal(session.LastReleaseOutcome, session.Release()); + Assert.Throws(() => session.Reset(Token)); + } + + /// + /// A default request is a programming error: the Try and the throwing forms throw what its factories or its + /// constructor throw, before any Cheat Engine call. + /// + [Fact] + public void DefaultRequestsThrowBeforeDispatch() + { + IValueScanSession session = CreateSession(); + + Assert.Throws(() => session.TryFirstScan(default, out _, Token)); + Assert.Throws(() => session.FirstScan(default, Token)); + Assert.Throws(() => session.TryNextScan(default, out _, Token)); + Assert.Throws(() => session.NextScan(default, Token)); + Assert.Throws(() => session.TryRead(default, out _, out _, Token)); + Assert.Throws(() => session.Read(default, Token)); + Assert.Empty(Handle.Calls); + } + + /// + /// A well-formed read beyond Cheat Engine's 32-bit result index is a limit, refused before dispatch. + /// + [Fact] + public void AReadBeyondCheatEngineResultIndexIsRefusedBeforeDispatch() + { + IValueScanSession session = CreateSession(); + + bool wide = session.TryRead(new ValueScanReadRequest((long) int.MaxValue + 1, 1), out _, + out CheatEngineFailure wideFailure, Token); + + Assert.False(wide); + Assert.Equal(CheatEngineFailureKind.ResultLimitExceeded, wideFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, wideFailure.HostEffect); + Assert.Equal(ValueScanSession.ReadOperation, wideFailure.Operation); + Assert.Empty(Handle.Calls); + } + + /// + /// Only a tampered value can be undefined: a first scan throws for it before dispatch through the option check + /// it shares with the AOB validation, as PatternScannerTests proves for the AOB route. + /// + [Theory] + [InlineData("Protection", "A value scan protection filter must use defined requirements.")] + [InlineData("AlignmentKind", "A value scan alignment must be created by a ScanAlignment factory.")] + [InlineData("AlignmentDivisor", "A value scan alignment must be created by a ScanAlignment factory.")] + public void ATamperedScanOptionThrowsBeforeDispatch(string tampered, string message) + { + IValueScanSession session = CreateSession(); + ValueScanFirstRequest valid = ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)); + ValueScanFirstRequest request = tampered switch + { + "Protection" => valid.WithProtection(TamperedValues.WithBackingField(default(ScanProtectionFilter), + nameof(ScanProtectionFilter.Writable), (ScanProtectionRequirement) 9)), + "AlignmentKind" => valid.WithAlignment(TamperedValues.WithBackingField(ScanAlignment.None, + nameof(ScanAlignment.Mode), (ScanAlignmentMode) 9)), + "AlignmentDivisor" => valid.WithAlignment(TamperedValues.WithBackingField(ScanAlignment.AlignedTo(4), + nameof(ScanAlignment.Mode), ScanAlignmentMode.None)), + _ => throw new ArgumentOutOfRangeException(nameof(tampered), tampered, null) + }; + + ArgumentOutOfRangeException thrown = + Assert.Throws(() => session.TryFirstScan(request, out _, Token)); + ArgumentOutOfRangeException throwingForm = + Assert.Throws(() => session.FirstScan(request, Token)); + + Assert.StartsWith(message, thrown.Message, StringComparison.Ordinal); + Assert.Equal("request", thrown.ParamName); + Assert.Equal(thrown.Message, throwingForm.Message); + Assert.Empty(Handle.Calls); + } + + /// + /// A next-scan value of an undefined type is tampered, not of another type than the first scan: both forms + /// throw for it before dispatch, with or without a first scan, instead of an OperationRejected + /// failure or an exception on Cheat Engine's main thread. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ANextScanValueOfAnUndefinedTypeThrowsBeforeDispatch(bool afterFirstScan) + { + IValueScanSession session = CreateSession(); + if (afterFirstScan) + { + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + } + + int calls = Handle.Calls.Count; + ValueScanNextRequest request = TamperedValues.WithBackingField( + ValueScanNextRequest.Exact(ValueScanValue.FromInt32(1)), nameof(ValueScanNextRequest.Value), + (ValueScanValue?) TamperedValues.WithBackingField(ValueScanValue.FromInt32(1), + nameof(ValueScanValue.ValueType), (ValueScanValueType) 99)); + + ArgumentOutOfRangeException thrown = + Assert.Throws(() => session.TryNextScan(request, out _, Token)); + _ = Assert.Throws(() => session.NextScan(request, Token)); + + Assert.Equal("request", thrown.ParamName); + Assert.Equal((ValueScanValueType) 99, thrown.ActualValue); + Assert.Equal(calls, Handle.Calls.Count); + Assert.Equal(afterFirstScan ? ValueScanSessionState.ResultsReady : ValueScanSessionState.Created, + session.State); + } + + [Fact] + public void AScanFaultBeforeCheatEngineIsCalledCarriesNoHostText() + { + IValueScanSession session = CreateSession(); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + Handle.HostErrorText = "stale text"; + + bool scanned = session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), + out CheatEngineFailure failure, Token); + + Assert.False(scanned); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.DoesNotContain("stale text", failure.Message, StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ASessionForAProcessSelectedInCheatEngineStaysWithThatProcess() + { + long firstEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + FakeValueScanSessionHandle first = Handle; + first.OwnerReleases = (TargetReleaseStatus.RefusedTargetChanged, TargetReleaseStatus.RefusedTargetChanged); + IValueScanSession forFirst = CreateSession(); + // Cheat Engine's own window selects another process: no Client call observes it. + _target.Select(FakeSelectedTarget.OtherProcessIncarnation); + FakeValueScanSessionHandle second = new() + { + TargetIncarnation = FakeSelectedTarget.OtherProcessIncarnation + }; + _port.Session = second; + + IValueScanSession forSecond = CreateSession(); + int secondDestroysAfterCreation = second.Destroys; + long secondEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + forSecond.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), Token); + + // The first session's process is no longer selected: it was released when the second session was bound. + Assert.True(forFirst.IsReleased); + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, forFirst.LastReleaseOutcome?.Kind); + Assert.Equal(firstEpoch, forFirst.SelectionEpoch); + // The second session belongs to the selection the next observation finds, which releases nothing. + Assert.True(secondEpoch > firstEpoch); + Assert.Equal(secondEpoch, forSecond.SelectionEpoch); + Assert.Equal(0, secondDestroysAfterCreation); + Assert.Equal(0, second.Destroys); + Assert.False(forSecond.IsReleased); + Assert.Equal(ValueScanSessionState.ResultsReady, forSecond.State); + } + + [Fact] + public void ASessionInTheObservedProcessKeepsTheObservedSelectionEpoch() + { + long observedEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + + IValueScanSession session = CreateSession(); + long laterEpoch = _processes.GetCurrentProcess(Token).SelectionEpoch; + + Assert.Equal(observedEpoch, session.SelectionEpoch); + Assert.Equal(observedEpoch, laterEpoch); + Assert.False(session.IsReleased); + Assert.Equal(0, Handle.Destroys); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void ASessionDuringTheDeactivationCleanupIsRefusedBeforeCheatEngineCreatesIt() + { + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + Assert.Throws(() => _scanner.TryCreateSession(out _, out _, Token)); + } + + Assert.Equal(0, _port.Creations); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void AnActivationStoppingDuringTheCreationReleasesTheSessionAndThrows() + { + _port.DuringCreate = _context.Stop; + + CheatEngineInvalidStateException stopping = Assert.Throws(() => + _scanner.TryCreateSession(out _, out _, Token)); + + Assert.Contains("the new scan session, which was released at once", stopping.Message, StringComparison.Ordinal); + Assert.Equal(ValueScanner.CreateOperation, stopping.Failure.Operation); + Assert.Equal(1, Handle.Destroys); + } + + private IValueScanSession CreateSession() + { + Assert.True(_scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, Token), + failure.ToString()); + return session; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CancellationMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CancellationMappingTests.cs new file mode 100644 index 0000000..3872c74 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CancellationMappingTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +/// +/// Proves the cancellation vocabulary: before the native call a cancellation is +/// ; after it returned, , +/// and nothing copied from the call is published; between two native calls of one operation, +/// . +/// +public sealed class CancellationMappingTests +{ + [Fact] + public void ACancellationBeforeTheNativeCallIsNotStarted() + { + CheatEngineFailure failure = CancellationMapping.BeforeNativeCall("Memory.ReadBytes"); + + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("Memory.ReadBytes", failure.Operation); + Assert.Equal(CancellationMapping.BeforeNativeCallMessage, failure.Message); + Assert.Null(failure.Exception); + } + + [Fact] + public void ACancellationAfterTheNativeCallIsCompleted() + { + CheatEngineFailure failure = CancellationMapping.AfterNativeCall("Patterns.Scan"); + + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(CancellationMapping.AfterNativeCallMessage, failure.Message); + Assert.Null(failure.Exception); + } + + [Fact] + public void ACancellationBetweenTwoNativeCallsIsStarted() + { + CheatEngineFailure failure = CancellationMapping.BetweenNativeCalls("ValueScans.FirstScan"); + + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + Assert.Equal("ValueScans.FirstScan", failure.Operation); + Assert.Equal(CancellationMapping.BetweenNativeCallsMessage, failure.Message); + Assert.Null(failure.Exception); + } + + [Fact] + public void AnOperationSpecificMessageKeepsTheCancellationEffect() + { + CheatEngineFailure before = CancellationMapping.BeforeNativeCall("Patterns.Scan", "Cancelled before the scan."); + CheatEngineFailure after = CancellationMapping.AfterNativeCall("Patterns.Scan", "Cancelled after the scan."); + CheatEngineFailure between = CancellationMapping.BetweenNativeCalls("ValueScans.NextScan", "Cancelled before the wait."); + + Assert.Equal("Cancelled before the scan.", before.Message); + Assert.Equal(CheatEngineHostEffect.NotStarted, before.HostEffect); + Assert.Equal("Cancelled after the scan.", after.Message); + Assert.Equal(CheatEngineHostEffect.Completed, after.HostEffect); + Assert.Equal("Cancelled before the wait.", between.Message); + Assert.Equal(CheatEngineHostEffect.Started, between.HostEffect); + } + + [Fact] + public void ACancelledTableSearchIsCompletedAndPublishesNothing() + { + using CancellationTokenSource cancellation = new(); + CancellingLookupPort lookups = new(cancellation.Cancel); + CoreLifetime lifetime = InertCoreLifetime.Create(); + TableClient client = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), + CoreClientPolicy.SafeDefaults, lifetime: lifetime, recordLookups: lookups); + + bool succeeded = client.TryFind(new MemoryRecordSearch("Ammo"), new MemoryRecordCollectionRequest(8), + out ImmutableArray records, out CheatEngineFailure failure, cancellation.Token); + + Assert.False(succeeded); + Assert.True(records.IsEmpty); + Assert.Equal(1, lookups.TableReads); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("Tables.Find", failure.Operation); + } + + /// Copies one matching record and cancels the caller's token while the snapshot is taken. + private sealed class CancellingLookupPort(Action onTableRead) : ITableRecordLookupPort + { + internal int TableReads + { + get; + private set; + } + + public RecordLookupStatus TryGetRecord(int index, out MemoryRecordSnapshot record) + { + throw new InvalidOperationException("Not used by the search."); + } + + public RecordLookupStatus TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record) + { + throw new InvalidOperationException("Not used by the search."); + } + + public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record) + { + throw new InvalidOperationException("Not used by the search."); + } + + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table) + { + TableReads++; + onTableRead(); + table = new AddressTableSnapshot([ + new MemoryRecordSnapshot(new MemoryRecordId(1), 0, + new MemoryRecordContentSnapshot("Ammo", "game.exe+24", "50", VariableType.Dword), + new MemoryRecordStateSnapshot(null)) + ]); + return RecordLookupStatus.Success; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/ConsumedSdkIdentityTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/ConsumedSdkIdentityTests.cs new file mode 100644 index 0000000..7da9022 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/ConsumedSdkIdentityTests.cs @@ -0,0 +1,164 @@ +using System.Globalization; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Runtime; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +/// +/// The package gate follows the CheatEngine.SDK range the Client packages declare: a loaded CheatEngine.SDK.Engine of +/// the supported major at or above the pin, by SemVer precedence, is , +/// and the gate says whether it is exactly the reviewed package (audit ADR-09, ADR-10). +/// +public sealed class ConsumedSdkIdentityTests +{ + private const string SdkVersion = "2.0.0"; + private const string SdkCommit = "325c47b573f8bd39a247f1d0101f110fa36c1696"; + private const string SdkSupportedMajor = "2"; + + private const string SdkContentHash = + "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ=="; + + private const string OtherCommit = "0123456789abcdef0123456789abcdef01234567"; + + [Theory] + [InlineData(SdkVersion + "+" + SdkCommit, ClientCapabilityEvidenceState.Satisfied, true, "the reviewed package")] + [InlineData("2.0.1", ClientCapabilityEvidenceState.Satisfied, false, "another release of 2.x at or above 2.0.0")] + [InlineData("2.1.0-beta.1", ClientCapabilityEvidenceState.Satisfied, false, + "another release of 2.x at or above 2.0.0")] + [InlineData("2.0.0-rc.1", ClientCapabilityEvidenceState.Missing, false, "outside 2.x at or above 2.0.0")] + [InlineData("1.0.0", ClientCapabilityEvidenceState.Missing, false, "outside 2.x at or above 2.0.0")] + [InlineData("3.0.0", ClientCapabilityEvidenceState.Missing, false, "outside 2.x at or above 2.0.0")] + [InlineData(null, ClientCapabilityEvidenceState.Unknown, false, "not compared")] + public void PackageGateAcceptsEveryReleaseOfTheSupportedMajorAtOrAboveThePin(string? loaded, + ClientCapabilityEvidenceState expected, bool exact, string label) + { + ConsumedSdkIdentity identity = Identity(loaded); + + Assert.Equal(expected, identity.PackageGate.State); + Assert.Equal(exact, identity.ExactReviewedIdentity); + Assert.Equal(label, identity.IdentityLabel); + Assert.Equal(2, identity.SupportedMajor); + Assert.DoesNotContain("refuse", identity.PackageGate.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(SdkVersion)] + [InlineData(SdkVersion + "+" + OtherCommit)] + [InlineData("2.0.1+" + SdkCommit)] + public void AnotherBuildOfASupportedVersionIsAcceptedButIsNotTheReviewedPackage(string loaded) + { + ConsumedSdkIdentity identity = Identity(loaded); + + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, identity.PackageGate.State); + Assert.False(identity.ExactReviewedIdentity); + Assert.Contains( + $"another release than the reviewed package this Client build consumed ({SdkVersion}+{SdkCommit})", + identity.PackageGate.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("2.0.0-alpha", ClientCapabilityEvidenceState.Missing)] + [InlineData("2.0.0-alpha.1", ClientCapabilityEvidenceState.Missing)] + [InlineData("2.0.0-alpha.beta", ClientCapabilityEvidenceState.Missing)] + [InlineData("2.0.0-beta", ClientCapabilityEvidenceState.Missing)] + [InlineData("2.0.0-beta.2", ClientCapabilityEvidenceState.Satisfied)] + [InlineData("2.0.0-beta.11", ClientCapabilityEvidenceState.Satisfied)] + [InlineData("2.0.0-rc.1", ClientCapabilityEvidenceState.Satisfied)] + [InlineData("2.0.0", ClientCapabilityEvidenceState.Satisfied)] + [InlineData("2.0.0-beta.2+" + OtherCommit, ClientCapabilityEvidenceState.Satisfied)] + public void PrereleasesCompareBySemVerPrecedence(string loaded, ClientCapabilityEvidenceState expected) + { + // The precedence chain of https://semver.org/#spec-item-11 around a prerelease pin: numeric identifiers compare + // numerically (beta.11 is above beta.2), a shorter identifier list is lower, and a release is above its + // prereleases. The build never embeds a prerelease pin (CHEATENGINECLIENT9016); the comparison does not rely on it. + ConsumedSdkIdentity identity = new("2.0.0-beta.2", SdkCommit, SdkContentHash, SdkSupportedMajor, loaded); + + Assert.Equal(expected, identity.PackageGate.State); + } + + [Theory] + [InlineData("2.0")] + [InlineData("2.0.0.0")] + [InlineData("02.0.0")] + [InlineData("2.0.0-")] + [InlineData("2.0.0-01")] + [InlineData("2.0.0-beta..1")] + [InlineData("2.0.0+")] + [InlineData("2.0.0+build/1")] + [InlineData("99999999999.0.0")] + [InlineData("not a version")] + public void InformationalVersionThatIsNotASemanticVersionIsUnknown(string loaded) + { + ConsumedSdkIdentity identity = Identity(loaded); + + Assert.Equal(ClientCapabilityEvidenceState.Unknown, identity.PackageGate.State); + Assert.False(identity.ExactReviewedIdentity); + Assert.Contains("is not a semantic version", identity.PackageGate.Reason, StringComparison.Ordinal); + Assert.Equal("not compared", identity.IdentityLabel); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("two")] + [InlineData("02")] + [InlineData("-2")] + public void IdentityWithoutASupportedMajorIsNotEmbedded(string? supportedMajor) + { + ConsumedSdkIdentity identity = new(SdkVersion, SdkCommit, SdkContentHash, supportedMajor, + $"{SdkVersion}+{SdkCommit}"); + + Assert.False(identity.IsEmbedded); + Assert.Null(identity.SupportedMajor); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, identity.PackageGate.State); + Assert.False(identity.ExactReviewedIdentity); + Assert.Contains("embeds no consumed CheatEngine.SDK identity", identity.PackageGate.Reason, + StringComparison.Ordinal); + } + + [Theory] + [InlineData("3")] + [InlineData("1")] + public void PinOfAnotherMajorThanTheSupportedOneIsUnknown(string supportedMajor) + { + ConsumedSdkIdentity identity = new(SdkVersion, SdkCommit, SdkContentHash, supportedMajor, + $"{SdkVersion}+{SdkCommit}"); + + Assert.True(identity.IsEmbedded); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, identity.PackageGate.State); + Assert.False(identity.ExactReviewedIdentity); + Assert.Contains("which is not a pin of that major", identity.PackageGate.Reason, StringComparison.Ordinal); + } + + [Fact] + public void NotEmbeddedIdentityComparesNothing() + { + ConsumedSdkIdentity identity = ConsumedSdkIdentity.NotEmbedded; + + Assert.False(identity.IsEmbedded); + Assert.Null(identity.ExpectedInformationalVersion); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, identity.PackageGate.State); + Assert.False(identity.ExactReviewedIdentity); + Assert.Equal("not compared", identity.IdentityLabel); + } + + [Fact] + public void CurrentIdentityCarriesTheSupportedMajorOfItsPin() + { + // The Core assembly under test embeds the pin and the supported major of eng/CheatEngineSdk.props, and the test + // process loads the reviewed package. + ConsumedSdkIdentity current = ConsumedSdkIdentity.Current; + + Assert.True(current.IsEmbedded, "The Core assembly under test embeds no consumed CheatEngine.SDK identity."); + Assert.Equal(current.Version!.Split('.')[0], + current.SupportedMajor?.ToString(CultureInfo.InvariantCulture)); + Assert.True(current.ExactReviewedIdentity); + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, current.PackageGate.State); + } + + private static ConsumedSdkIdentity Identity(string? loaded) + { + return new ConsumedSdkIdentity(SdkVersion, SdkCommit, SdkContentHash, SdkSupportedMajor, loaded); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreDiagnosticsTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreDiagnosticsTests.cs new file mode 100644 index 0000000..566076a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreDiagnosticsTests.cs @@ -0,0 +1,806 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +using SymbolRegistrationLease = CheatEngine.Client.Core.Domains.SymbolRegistrationLease; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +/// +/// The Core diagnostic events (audit ch.24, A24-12 to A24-17, A24-22): emitted after the dispatched Cheat Engine work +/// returned, bounded to closed names, counts and epochs, and never able to change an operation result. +/// +public sealed partial class CoreDiagnosticsTests : IDisposable +{ + private const string SensitiveSymbol = "player_health"; + private const string ExistingSymbol = "existing_symbol"; + private const string SensitiveScript = "return readInteger('player_health')"; + private const ulong SensitiveAddress = 0x7FF6_1234_5678; + + private static readonly MemoryRecordId HandedOut = new(41); + + private readonly string _root = Directory.CreateTempSubdirectory("ce-client-diagnostics-").FullName; + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + } + + [Fact] + public void CoreDiagnosticsAreNeverEmittedInsideADispatchedCallback() + { + CallbackTracker tracker = new(); + RecordingCoreDiagnostics diagnostics = new(tracker); + + RunScenario(diagnostics, tracker); + + Assert.True(tracker.CallbackCount > 0, "The scripted run dispatched no callback; the test would pass vacuously."); + Assert.Equal( + [ + nameof(ICoreDiagnostics.RuntimeSnapshotCaptured), nameof(ICoreDiagnostics.TargetSelectionAdvanced), + nameof(ICoreDiagnostics.TargetSelectionAdvanced), nameof(ICoreDiagnostics.PointerWidthMismatchRefused), + nameof(ICoreDiagnostics.MemoryBatchCompleted), nameof(ICoreDiagnostics.TableGenerationAdvanced), + nameof(ICoreDiagnostics.StaleRecordIdentifierRefused), + nameof(ICoreDiagnostics.RecordActivationNotApplied), + nameof(ICoreDiagnostics.SymbolRegistrationRejected), nameof(ICoreDiagnostics.LeaseReleased), + nameof(ICoreDiagnostics.PatternScanCompleted), nameof(ICoreDiagnostics.LuaOperationCompleted), + nameof(ICoreDiagnostics.CapabilityRefused), + nameof(ICoreDiagnostics.CoreResourceCleanupFailed) + ], + diagnostics.Emissions.Select(static emission => emission.Event)); + Assert.All(diagnostics.Emissions, static emission => Assert.False(emission.InsideCallback, + $"{emission.Event} was emitted inside a dispatched callback.")); + } + + [Fact] + public void ScriptedRunEmitsEachDomainEventWithItsBoundedFields() + { + CallbackTracker tracker = new(); + RecordingCoreDiagnostics diagnostics = new(tracker); + + RunScenario(diagnostics, tracker); + + Assert.Equal(Fields(1L, CheatEngineArchitecture.X64, 8, 8, false), + diagnostics.Single(nameof(ICoreDiagnostics.RuntimeSnapshotCaptured))); + Assert.Equal( + [ + Fields(1L, 1L, "Processes.GetCurrentProcess", "PidChanged"), + Fields(1L, 2L, "Processes.GetCurrentProcess", "TargetDetached") + ], + diagnostics.All(nameof(ICoreDiagnostics.TargetSelectionAdvanced))); + Assert.Equal(Fields("Memory.ReadPrimitive", 8, 4), + diagnostics.Single(nameof(ICoreDiagnostics.PointerWidthMismatchRefused))); + Assert.Equal(Fields("Memory.ReadPrimitiveBatch", 2, 2, "ReadOnly"), + diagnostics.Single(nameof(ICoreDiagnostics.MemoryBatchCompleted))); + Assert.Equal(Fields(1L, 1L), diagnostics.Single(nameof(ICoreDiagnostics.TableGenerationAdvanced))); + Assert.Equal(Fields("Tables.SetActive", 1L), + diagnostics.Single(nameof(ICoreDiagnostics.StaleRecordIdentifierRefused))); + Assert.Equal(Fields("Tables.SetActive", true, "RefusedByHost"), + diagnostics.Single(nameof(ICoreDiagnostics.RecordActivationNotApplied))); + Assert.Equal(Fields("Inspection.RegisterSymbol", "AlreadyResolves"), + diagnostics.Single(nameof(ICoreDiagnostics.SymbolRegistrationRejected))); + Assert.Equal(Fields(SymbolRegistrationLease.ReleaseOperation, LeaseReleaseKind.Released, + CheatEngineHostEffect.Completed), + diagnostics.Single(nameof(ICoreDiagnostics.LeaseReleased))); + object?[] scan = diagnostics.Single(nameof(ICoreDiagnostics.PatternScanCompleted)); + Assert.Equal(Fields(PatternScanScope.GlobalHostScan, 1L, 1, false), scan[..4]); + object?[] lua = diagnostics.Single(nameof(ICoreDiagnostics.LuaOperationCompleted)); + Assert.Equal(Fields("Lua.Execute", "None", 0), Fields(lua[0], lua[1], lua[3])); + Assert.Equal( + [ + Fields(ClientCapabilityId.UnsafeLuaExecution.Value, "UnsafeLua.Execute", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing) + ], + diagnostics.All(nameof(ICoreDiagnostics.CapabilityRefused))); + Assert.Equal(Fields(nameof(ThrowingDisposable), typeof(InvalidOperationException).FullName), + diagnostics.Single(nameof(ICoreDiagnostics.CoreResourceCleanupFailed))); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void CoreDiagnosticsCarryOnlyClosedNamesCountsAndEpochs() + { + CallbackTracker tracker = new(); + RecordingCoreDiagnostics diagnostics = new(tracker); + + RunScenario(diagnostics, tracker); + + Assert.NotEmpty(diagnostics.Emissions); + foreach (Emission emission in diagnostics.Emissions) + { + foreach (object? argument in emission.Arguments) + { + Assert.True(argument is string or long or int or bool or Enum, + $"{emission.Event} carries a {argument?.GetType().FullName ?? "null"} argument."); + if (argument is string text) + { + Assert.Matches(ClosedName(), text); + Assert.DoesNotContain(SensitiveSymbol, text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(ExistingSymbol, text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("readInteger", text, StringComparison.Ordinal); + Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(_root, text, StringComparison.OrdinalIgnoreCase); + } + + long? number = argument switch + { + long value => value, + int value => value, + _ => null + }; + Assert.NotEqual(unchecked((long) SensitiveAddress), number); + } + } + } + + [Fact] + public void ThrowingCoreDiagnosticsSinkDoesNotChangeOperationResults() + { + CallbackTracker silentTracker = new(); + CallbackTracker throwingTracker = new(); + RecordingCoreDiagnostics throwing = new(throwingTracker, throwAfterRecording: true); + + IReadOnlyList withoutDiagnostics = RunScenario(null, silentTracker); + IReadOnlyList withThrowingDiagnostics = RunScenario(throwing, throwingTracker); + + Assert.Equal(withoutDiagnostics, withThrowingDiagnostics); + Assert.Equal(14, throwing.Emissions.Count); + Assert.Contains("UnsafeLua.Execute:False:CapabilityUnavailable", withThrowingDiagnostics); + Assert.Contains("Cleanup:InvalidOperationException", withThrowingDiagnostics); + } + + [Fact] + public void GuardedDiagnosticsWrapsOnceAndMapsNullToTheNullSink() + { + RecordingCoreDiagnostics inner = new(new CallbackTracker(), throwAfterRecording: true); + + ICoreDiagnostics guarded = GuardedCoreDiagnostics.Wrap(inner); + + Assert.Same(NullCoreDiagnostics.Instance, GuardedCoreDiagnostics.Wrap(null)); + Assert.IsType(guarded); + Assert.Same(guarded, GuardedCoreDiagnostics.Wrap(guarded)); + guarded.LeaseReleased(SymbolRegistrationLease.ReleaseOperation, LeaseReleaseKind.Replaced, + CheatEngineHostEffect.NotStarted); + Assert.Single(inner.Emissions); + } + + /// + /// Runs one operation of every domain that emits an event and returns the observable results; the Cheat Engine + /// ports are fakes and the dispatchers run callbacks inline while marks them. + /// + private List RunScenario(ICoreDiagnostics? diagnostics, CallbackTracker tracker) + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List outcomes = []; + FakeTarget target = new() + { + ProcessId = 100 + }; + CoreLifetime lifetime = InertCoreLifetime.Create(diagnostics); + FlaggingDispatcher dispatcher = new(tracker); + SdkMainThreadDispatcher mainThreadDispatcher = new(lifetime, new FlaggingInvoker(tracker)); + + RuntimeClient runtime = new(dispatcher, target, () => lifetime.Epoch, diagnostics: diagnostics); + outcomes.Add(Describe("Runtime.GetSnapshot", + runtime.TryGetSnapshot(out _, out CheatEngineFailure failure, cancellationToken), failure)); + + ProcessClient processes = new(dispatcher, target, target, target, lifetime); + outcomes.Add(Describe("Processes.First", + processes.TryGetCurrentProcess(out _, out failure, cancellationToken), failure)); + target.ProcessId = 200; + outcomes.Add(Describe("Processes.PidChanged", + processes.TryGetCurrentProcess(out _, out failure, cancellationToken), failure)); + target.ProcessId = 0; + outcomes.Add(Describe("Processes.Detached", + processes.TryGetCurrentProcess(out _, out failure, cancellationToken), failure)); + target.ProcessId = 200; + + MemoryClient memory = new(dispatcher, lifetime, target); + target.ConfiguredPointerSize = sizeof(uint); + outcomes.Add(Describe("Memory.ReadPointer", + memory.TryReadPrimitive(new Address(SensitiveAddress), out Address _, out failure, cancellationToken), + failure)); + target.ConfiguredPointerSize = sizeof(ulong); + MemoryPrimitiveBatchReadOutcome batch = memory.ReadPrimitiveBatchDetailed( + new MemoryPrimitiveBatchReadRequest([new Address(SensitiveAddress), new Address(SensitiveAddress + 4)]), + cancellationToken); + outcomes.Add($"Memory.Batch:{batch.IsSuccess}:{batch.CompletedCount}"); + + string tablePath = Path.Combine(_root, "trusted.ct"); + File.WriteAllText(tablePath, ""); + TableClient tables = new(dispatcher, new CoreClientPolicy([_root], false), new RefusingMutationPort(), lifetime, + new SingleRecordLookupPort(), new AcceptingFilePort()); + outcomes.Add(Describe("Tables.HandOut", tables.TryGetRecordAt(0, out _, out failure, cancellationToken), failure)); + outcomes.Add(Describe("Tables.Load", + tables.TryLoadTrustedTable(new TableLoadRequest(new TrustedTableFile(tablePath)), out failure, + cancellationToken), failure)); + outcomes.Add(Describe("Tables.Stale", + tables.TrySetActive(HandedOut, true, out _, out failure, cancellationToken), failure)); + outcomes.Add(Describe("Tables.Reobserve", tables.TryGetRecordAt(0, out _, out failure, cancellationToken), + failure)); + outcomes.Add(Describe("Tables.Refused", + tables.TrySetActive(HandedOut, true, out _, out failure, cancellationToken), failure)); + + InspectionClient inspection = new(mainThreadDispatcher, lifetime, new SymbolPort()); + outcomes.Add(Describe("Inspection.Collision", + inspection.TryRegisterSymbol(new SymbolRegistration(ExistingSymbol, new Address(0x2000)), out _, + out failure, cancellationToken), failure)); + bool registered = inspection.TryRegisterSymbol( + new SymbolRegistration(SensitiveSymbol, new Address(SensitiveAddress)), out ISymbolRegistrationLease? lease, + out failure, cancellationToken); + outcomes.Add(Describe("Inspection.Register", registered, failure)); + lease?.Dispose(); + + PatternScanner patterns = new(mainThreadDispatcher, new SingleMatchScanPort()); + outcomes.Add(Describe("Patterns.Scan", + patterns.TryScan(new AobScanRequest(new AobPattern("90"), 10, null, null), out _, + out failure, cancellationToken), failure)); + + LuaClient lua = new(dispatcher, () => lifetime.Epoch, () => true, diagnostics: diagnostics); + outcomes.Add(Describe("Lua.Execute", + lua.TryExecute(new ConstantOperation(), out _, out failure, cancellationToken), + failure)); + + UnsafeLuaClient unsafeLua = new(dispatcher, new CoreClientPolicy([_root], false), lifetime); + outcomes.Add(Describe("UnsafeLua.Execute", + unsafeLua.TryExecute(new LuaScript(SensitiveScript), out failure, cancellationToken), failure)); + + lifetime.Track(new ThrowingDisposable()); + Exception cleanup = Assert.ThrowsAny(lifetime.Dispose); + outcomes.Add($"Cleanup:{cleanup.GetType().Name}"); + return outcomes; + } + + private static object?[] Fields(params object?[] values) + { + return values; + } + + private static string Describe(string step, bool succeeded, CheatEngineFailure failure) + { + return succeeded ? $"{step}:True" : $"{step}:False:{failure.Kind}"; + } + + private static MemoryRecordSnapshot Snapshot(MemoryRecordId id) + { + return new MemoryRecordSnapshot(id, 0, + new MemoryRecordContentSnapshot("Health", "game.exe+24", "100", VariableType.Dword), + new MemoryRecordStateSnapshot(null)); + } + + [GeneratedRegex(@"^[A-Z][A-Za-z]*(\.[A-Z][A-Za-z]*)*$")] + private static partial Regex ClosedName(); + + private sealed record Emission(string Event, bool InsideCallback, object?[] Arguments); + + /// Marks the dynamic extent of every dispatched callback. + private sealed class CallbackTracker + { + private int _depth; + + internal bool InCallback => _depth > 0; + + internal int CallbackCount + { + get; + private set; + } + + internal T Run(Func callback) + { + CallbackCount++; + _depth++; + try + { + return callback(); + } + finally + { + _depth--; + } + } + } + + private sealed class FlaggingDispatcher(CallbackTracker tracker) : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + tracker.Run(() => + { + callback(); + return 0; + }); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + result = tracker.Run(callback); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + _ = TryInvoke(callback, out _, cancellationToken); + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + _ = TryInvoke(callback, out T? result, out _, cancellationToken); + return result!; + } + } + + private sealed class FlaggingInvoker(CallbackTracker tracker) : IMainThreadInvoker + { + public Exception? Invoke(Action callback) + { + try + { + tracker.Run(() => + { + callback(); + return 0; + }); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + public MainThreadInvocationResult Invoke(Func callback) + { + try + { + return new MainThreadInvocationResult(tracker.Run(callback), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + } + + /// Records every event with its arguments and whether a dispatched callback was running. + private sealed class RecordingCoreDiagnostics(CallbackTracker tracker, bool throwAfterRecording = false) + : ICoreDiagnostics + { + internal List Emissions + { + get; + } = []; + + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch) + { + Record(nameof(RuntimeSnapshotCaptured), activationEpoch, targetArchitecture, processPointerBytes, + configuredPointerBytes, pointerSizeMismatch); + } + + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState) + { + Record(nameof(CapabilityRefused), capability, operation, gate, gateState); + } + + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason) + { + Record(nameof(TargetSelectionAdvanced), activationEpoch, selectionEpoch, operation, reason); + } + + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes) + { + Record(nameof(PointerWidthMismatchRefused), operation, processPointerBytes, configuredPointerBytes); + } + + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState) + { + Record(nameof(MemoryBatchCompleted), operation, requested, completed, effectState); + } + + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration) + { + Record(nameof(TableGenerationAdvanced), activationEpoch, tableGeneration); + } + + public void StaleRecordIdentifierRefused(string operation, long tableGeneration) + { + Record(nameof(StaleRecordIdentifierRefused), operation, tableGeneration); + } + + public void RecordActivationNotApplied(string operation, bool requestedState, string status) + { + Record(nameof(RecordActivationNotApplied), operation, requestedState, status); + } + + public void SymbolRegistrationRejected(string operation, string reason) + { + Record(nameof(SymbolRegistrationRejected), operation, reason); + } + + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, + bool truncated, long hostScanMilliseconds, long copyMilliseconds) + { + Record(nameof(PatternScanCompleted), scope, hostResultCount, materializedCount, truncated, + hostScanMilliseconds, copyMilliseconds); + } + + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength) + { + Record(nameof(LuaOperationCompleted), operation, outcome, elapsedMilliseconds, scriptLength); + } + + public void CoreResourceCleanupFailed(string componentType, string exceptionType) + { + Record(nameof(CoreResourceCleanupFailed), componentType, exceptionType); + } + + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + Record(nameof(LeaseReleased), operation, kind, hostEffect); + } + + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch) + { + Record(nameof(AutoAssemblerPatchAppliedAfterTargetChange), operation, selectionEpoch); + } + + internal object?[] Single(string eventName) + { + return Assert.Single(All(eventName)); + } + + internal object?[][] All(string eventName) + { + return Emissions.Where(emission => emission.Event == eventName).Select(static emission => emission.Arguments) + .ToArray(); + } + + private void Record(string eventName, params object?[] arguments) + { + Emissions.Add(new Emission(eventName, tracker.InCallback, arguments)); + if (throwAfterRecording) + { + throw new InvalidOperationException("The diagnostics sink failed."); + } + } + } + + /// + /// One selected 64-bit x86 target: the process host, the memory port and the runtime observation port at once. + /// + private sealed class FakeTarget : FakeRuntimeObservationPort, IProcessHost, IMemoryCodecContextPort + { + internal long ProcessId + { + get; + set + { + field = value; + Synchronize(); + } + } + + internal int ConfiguredPointerSize + { + get; + set + { + field = value; + Synchronize(); + } + } = sizeof(ulong); + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + process = default; + return false; + } + + public IReadOnlyList GetLocalProcesses() + { + return []; + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + return []; + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + destination.Clear(); + written = destination.Length; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) + { + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryReadPrimitive(Address address, out T value, out MemoryAccessFailure failure) + { + value = default!; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWritePrimitive(Address address, T value, out MemoryAccessFailure failure) + { + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, + out MemoryAccessFailure failure) + { + value = default; + failure = MemoryAccessFailure.None; + return true; + } + + public bool TryWritePointer(Address address, Address value, PointerSize pointerSize, + out MemoryAccessFailure failure) + { + failure = MemoryAccessFailure.None; + return true; + } + + private void Synchronize() + { + TargetStatus = ProcessId == 0 ? ProcessOperationStatus.TargetNotAttached : ProcessOperationStatus.Success; + Target = TargetObservations.Create((int) Math.Max(ProcessId, 1), + configuredPointerSizeBytes: ConfiguredPointerSize); + } + } + + private sealed class SingleRecordLookupPort : ITableRecordLookupPort + { + public RecordLookupStatus TryGetRecord(int index, out MemoryRecordSnapshot record) + { + record = Snapshot(HandedOut); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record) + { + record = Snapshot(id); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record) + { + record = Snapshot(HandedOut); + return RecordLookupStatus.Success; + } + + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table) + { + table = new AddressTableSnapshot([Snapshot(HandedOut)]); + return RecordLookupStatus.Success; + } + } + + /// A record whose activation callback refuses every change. + private sealed class RefusingMutationPort : ITableRecordMutationPort + { + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record) + { + record = default; + return TableRecordCreation.Created; + } + + public TableRecordMutationOutcome TryDelete(MemoryRecordId id) + { + return TableRecordMutationOutcome.Succeeded; + } + + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + record = Snapshot(childId); + return TableRecordMutationOutcome.Succeeded; + } + + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested) + { + return new TableActivationObservation(MemoryRecordActivationOutcomeKind.RefusedByHost, + MemoryRecordMutationProblem.None, Snapshot(id)); + } + + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record) + { + record = Snapshot(id); + return TableRecordMutationOutcome.Succeeded; + } + } + + private sealed class AcceptingFilePort : ITableFilePort + { + public LuaOperationStatus TryLoad(string path, bool merge) + { + return LuaOperationStatus.Success; + } + + public LuaOperationStatus TrySave(string path) + { + return LuaOperationStatus.Success; + } + } + + /// A symbol table in which already resolves. + private sealed class SymbolPort : IInspectionPort + { + private readonly Dictionary _symbols = new(StringComparer.Ordinal) + { + [ExistingSymbol] = new Address(0x1000) + }; + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + written = 0; + return InspectionStatus.Success; + } + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written) + { + written = 0; + return InspectionStatus.Success; + } + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, + out int written) + { + written = 0; + return InspectionStatus.Success; + } + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written) + { + written = 0; + return InspectionStatus.Success; + } + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region) + { + region = default; + return InspectionStatus.NotFound; + } + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol) + { + symbol = default; + return InspectionStatus.NotFound; + } + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, + out Address address) + { + return _symbols.TryGetValue(expression.Value, out address) + ? InspectionStatus.Success + : InspectionStatus.NotFound; + } + + public LuaOperationStatus TryGetName(Address address, out string? name) + { + name = null; + return LuaOperationStatus.NilResult; + } + + public SymbolRegistrationAttempt TryRegisterOwned(SymbolName name, Address address, + SymbolRegistrationOptions options) + { + _symbols[name.Value] = address; + return new SymbolRegistrationAttempt(LuaOperationStatus.Success, new Registration(_symbols, name.Value)); + } + + /// Unregisters the name once, as the SDK lease does when the name still maps to the address. + private sealed class Registration(Dictionary symbols, string name) : ISymbolRegistrationHandle + { + public SymbolRegistrationReleaseKind Release() + { + return symbols.Remove(name) + ? SymbolRegistrationReleaseKind.Released + : SymbolRegistrationReleaseKind.AlreadyReleased; + } + } + } + + private sealed class SingleMatchScanPort : IAobScanPort + { + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches) + { + matches = new SingleMatchList(); + return AobHosts.Outcome(AobScanOutcomeKind.Matches, 1); + } + + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + throw new NotSupportedException("The diagnostics scan is unscoped."); + } + + public TargetSelectionFacts ObserveSelection() + { + throw new NotSupportedException("The diagnostics scan is unscoped."); + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + written = 0; + return InspectionStatus.Success; + } + } + + private sealed class SingleMatchList : IAobMatchList + { + public bool TryGetCount(out int count) + { + count = 1; + return true; + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + value = index == 0 ? "7FF612345678" : null; + return value is not null; + } + + public TargetReleaseStatus Release() + { + return TargetReleaseStatus.Released; + } + } + + private readonly record struct ConstantOperation : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + result = 42; + failure = default; + return true; + } + } + + private sealed class ThrowingDisposable : IDisposable + { + public void Dispose() + { + throw new InvalidOperationException("The release of C:\\Users\\player\\secret.ct failed."); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs index da542e9..b8a13a9 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs @@ -1,6 +1,12 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Tests.Infrastructure; @@ -10,7 +16,7 @@ public sealed class CoreFailureFactoryTests [Fact] public void LifecycleCreatesTheStableInvalidStateFailure() { - CheatEngineFailure failure = CoreFailureFactory.Lifecycle("Client.DrainResources", "The client is stopping."); + CheatEngineFailure failure = CoreFailureFactory.InvalidState("Client.DrainResources", "The client is stopping."); Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); Assert.Equal("Client.DrainResources", failure.Operation); @@ -21,7 +27,8 @@ public void LifecycleCreatesTheStableInvalidStateFailure() [Fact] public void FromExceptionPreservesDedicatedActivationExpiryClassification() { - CheatEngineActivationExpiredException exception = new("Memory.Read", "The epoch changed."); + Exception exception = new CheatEngineFailure(CheatEngineFailureKind.ActivationExpired, "Memory.Read", + "The epoch changed.").ToException(TestContext.Current.CancellationToken); CheatEngineFailure failure = CoreFailureFactory.FromException("Dispatcher.Invoke", exception); @@ -47,7 +54,8 @@ public void FromExceptionMapsEachStableClientFailureCategory(string scenario, Ch { Exception exception = scenario switch { - "lifecycle" => new CheatEngineClientLifecycleException("Client.Test", "Lifecycle stopped."), + "lifecycle" => new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Client.Test", + "Lifecycle stopped.").ToException(TestContext.Current.CancellationToken), "capability" => new EngineCapabilityUnavailableException("Client.Test"), "global" => new EngineGlobalUnavailableException("Client.Test"), "operation" => new EngineOperationFailedException("Client.Test", "Host rejected the operation."), @@ -67,4 +75,134 @@ public void FromExceptionMapsEachStableClientFailureCategory(string scenario, Ch Assert.Equal("Client.MapFailure", failure.Operation); Assert.Same(exception, failure.Exception); } + + [Theory] + [InlineData("target-changed", CheatEngineFailureKind.TargetChanged, CheatEngineHostEffect.Unknown)] + [InlineData("target-process-reused", CheatEngineFailureKind.TargetChanged, CheatEngineHostEffect.Unknown)] + [InlineData("target-unqualified", CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.Unknown)] + [InlineData("target-unspecified", CheatEngineFailureKind.TargetIdentityUnavailable, CheatEngineHostEffect.Unknown)] + [InlineData("resource-handoff", CheatEngineFailureKind.BindingError, CheatEngineHostEffect.CleanupUnconfirmed)] + [InlineData("symbol-handoff", CheatEngineFailureKind.BindingError, CheatEngineHostEffect.CleanupUnconfirmed)] + [InlineData("symbol-list-handoff", CheatEngineFailureKind.BindingError, CheatEngineHostEffect.CleanupUnconfirmed)] + [InlineData("memory-scan-state", CheatEngineFailureKind.InvalidState, CheatEngineHostEffect.Unknown)] + public void FromExceptionClassifiesTheSdkTwoExceptionTypes(string scenario, CheatEngineFailureKind expectedKind, + CheatEngineHostEffect expectedHostEffect) + { + Exception exception = CreateSdkTwoException(scenario); + + CheatEngineFailure failure = CoreFailureFactory.FromException("Client.MapFailure", exception); + + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(expectedHostEffect, failure.HostEffect); + Assert.Equal("Client.MapFailure", failure.Operation); + Assert.Same(exception, failure.Exception); + } + + [Theory] + [InlineData("resource-handoff")] + [InlineData("symbol-handoff")] + [InlineData("symbol-list-handoff")] + public void FromExceptionKeepsAKnownHostEffectOfAHandoffException(string scenario) + { + CheatEngineFailure failure = CoreFailureFactory.FromException("Client.MapFailure", CreateSdkTwoException(scenario), + CheatEngineHostEffect.Started); + + Assert.Equal(CheatEngineFailureKind.BindingError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Started, failure.HostEffect); + } + + [Fact] + public void MemoryScanExceptionsAreClassifiedBeforeTheInvalidOperationArm() + { + Exception state = CreateSdkTwoException("memory-scan-state"); + MemoryScanException scan = CreateMemoryScanException(MemoryScanFailureKind.RuntimeInvalidated); + + Assert.IsType(state, exactMatch: false); + Assert.IsType(scan, exactMatch: false); + Assert.Equal(CheatEngineFailureKind.InvalidState, CoreFailureFactory.GetKind(state)); + Assert.Equal(CheatEngineFailureKind.RuntimeChanged, CoreFailureFactory.GetKind(scan)); + Assert.Equal(CheatEngineFailureKind.OperationRejected, + CoreFailureFactory.GetKind(new InvalidOperationException("Invalid state."))); + } + + [Theory] + [InlineData(MemoryScanFailureKind.MissingCapability, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(MemoryScanFailureKind.LuaError, CheatEngineFailureKind.LuaError)] + [InlineData(MemoryScanFailureKind.UnexpectedResult, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(MemoryScanFailureKind.RuntimeInvalidated, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(MemoryScanFailureKind.TargetIdentityUnavailable, CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData(MemoryScanFailureKind.TargetIdentityMismatch, CheatEngineFailureKind.TargetChanged)] + [InlineData((MemoryScanFailureKind) 99, CheatEngineFailureKind.Unknown)] + public void MemoryScanExceptionIsClassifiedByItsFailureKind(MemoryScanFailureKind scanKind, + CheatEngineFailureKind expectedKind) + { + MemoryScanException exception = CreateMemoryScanException(scanKind); + + Assert.Equal(expectedKind, CoreFailureFactory.FromException("ValueScans.Next", exception).Kind); + Assert.Equal(expectedKind, CoreFailureFactory.FromMemoryScanFailureKind(scanKind)); + } + + [Theory] + [InlineData(EngineFailureKind.ExpectedOperationFailure, CheatEngineFailureKind.OperationRejected)] + [InlineData(EngineFailureKind.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(EngineFailureKind.CapabilityUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(EngineFailureKind.ProtectedLuaFailure, CheatEngineFailureKind.LuaError)] + [InlineData(EngineFailureKind.BindingFailure, CheatEngineFailureKind.BindingError)] + [InlineData(EngineFailureKind.MarshallingFailure, CheatEngineFailureKind.InvalidHostResult)] + [InlineData(EngineFailureKind.TargetIdentityUnavailable, CheatEngineFailureKind.TargetIdentityUnavailable)] + [InlineData(EngineFailureKind.TargetIdentityMismatch, CheatEngineFailureKind.TargetChanged)] + [InlineData((EngineFailureKind) 99, CheatEngineFailureKind.Unknown)] + public void EngineFailureKindMapsToItsClientKind(EngineFailureKind engineKind, CheatEngineFailureKind expectedKind) + { + Assert.Equal(expectedKind, CoreFailureFactory.FromEngineFailureKind(engineKind)); + } + + [Fact] + public void CancelledIsTheBeforeNativeCallCancellation() + { + Assert.Equal(CancellationMapping.BeforeNativeCall("Dispatcher.Invoke"), + CoreFailureFactory.Cancelled("Dispatcher.Invoke")); + } + + private static Exception CreateSdkTwoException(string scenario) + { + return scenario switch + { + "target-changed" => new EngineTargetIdentityException("Client.Test", + CreateCheck(TargetIdentityCheckKind.TargetChanged)), + "target-process-reused" => new EngineTargetIdentityException("Client.Test", + CreateCheck(TargetIdentityCheckKind.ProcessReused)), + "target-unqualified" => new EngineTargetIdentityException("Client.Test", + CreateCheck(TargetIdentityCheckKind.CurrentTargetUnqualified)), + "target-unspecified" => new EngineTargetIdentityException("Client.Test", default), + "resource-handoff" => new EngineResourceHandoffException("Client.Test", default, null), + "symbol-handoff" => new SymbolRegistrationHandoffException(default, null), + "symbol-list-handoff" => new SymbolListRegistrationHandoffException(default, null), + // The SDK constructs MemoryScanStateException internally only; the classification reads its type alone. + "memory-scan-state" => (Exception) RuntimeHelpers.GetUninitializedObject(typeof(MemoryScanStateException)), + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null) + }; + } + + /// Builds the SDK's target validation result, whose constructor is internal to CheatEngine.SDK. + private static TargetIdentityCheck CreateCheck(TargetIdentityCheckKind kind) + { + ConstructorInfo constructor = typeof(TargetIdentityCheck).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + [typeof(TargetIdentityCheckKind), typeof(TargetSelectionObservation)]) + ?? throw new InvalidOperationException("TargetIdentityCheck has no (kind, observed) constructor."); + return (TargetIdentityCheck) constructor.Invoke([kind, default(TargetSelectionObservation)]); + } + + /// Builds a memory-scan failure; CheatEngine.SDK constructs it internally only. + private static MemoryScanException CreateMemoryScanException(MemoryScanFailureKind kind) + { + MemoryScanException exception = + (MemoryScanException) RuntimeHelpers.GetUninitializedObject(typeof(MemoryScanException)); + FieldInfo field = typeof(MemoryScanException).GetField("k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("MemoryScanException.FailureKind is no longer an auto-property."); + field.SetValue(exception, kind); + return exception; + } } diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs index c5d2896..15cd8fe 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs @@ -9,7 +9,10 @@ public sealed class CoreLifetimeBehaviorTests [Fact] public void ActiveContextExposesItsEpochAndAdmitsOrdinaryWork() { - using ControlledCoreLifetimeContext context = new() { Epoch = 42 }; + using ControlledCoreLifetimeContext context = new() + { + Epoch = 42 + }; using CoreLifetime lifetime = new(context); lifetime.ThrowIfInactive("Test.Work"); @@ -27,7 +30,7 @@ public void StoppingContextRejectsOrdinaryWorkButAllowsMainThreadCleanupScope() using CoreLifetime lifetime = new(context); context.Stop(); - CheatEngineClientLifecycleException rejected = Assert.Throws(() => + CheatEngineInvalidStateException rejected = Assert.Throws(() => lifetime.ThrowIfInactive("Test.Work")); Assert.Equal("Test.Work", rejected.Failure.Operation); Assert.False(lifetime.CanDispatch); @@ -35,7 +38,7 @@ public void StoppingContextRejectsOrdinaryWorkButAllowsMainThreadCleanupScope() using (lifetime.EnterCleanupScope()) { Assert.True(lifetime.CanDispatch); - lifetime.ThrowIfDispatchAllowed("Test.Cleanup"); + lifetime.ThrowIfDispatchRefused("Test.Cleanup"); } Assert.False(lifetime.CanDispatch); @@ -44,10 +47,13 @@ public void StoppingContextRejectsOrdinaryWorkButAllowsMainThreadCleanupScope() [Fact] public void CleanupScopeRequiresTheCapturedMainThread() { - using ControlledCoreLifetimeContext context = new() { IsMainThread = false }; + using ControlledCoreLifetimeContext context = new() + { + IsMainThread = false + }; using CoreLifetime lifetime = new(context); - CheatEngineClientLifecycleException exception = Assert.Throws( + CheatEngineInvalidStateException exception = Assert.Throws( lifetime.EnterCleanupScope); Assert.Equal("Client.EnterCleanupScope", exception.Failure.Operation); @@ -57,13 +63,16 @@ public void CleanupScopeRequiresTheCapturedMainThread() [Fact] public void StaleContextRejectsAllLifecycleAdmissionsWithActivationExpired() { - using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using ControlledCoreLifetimeContext context = new() + { + IsCurrent = false + }; using CoreLifetime lifetime = new(context); CheatEngineActivationExpiredException inactive = Assert.Throws(() => lifetime.ThrowIfInactive("Test.Inactive")); CheatEngineActivationExpiredException dispatch = Assert.Throws(() => - lifetime.ThrowIfDispatchAllowed("Test.Dispatch")); + lifetime.ThrowIfDispatchRefused("Test.Dispatch")); Assert.Equal("Test.Inactive", inactive.Failure.Operation); Assert.Equal("Test.Dispatch", dispatch.Failure.Operation); @@ -87,7 +96,40 @@ public void DisposeDrainsTrackedResourcesOnlyOnceAndClosesTheLifetime() Assert.Throws(() => lifetime.ThrowIfInactive("Test.Disposed")); } - private sealed class RecordingDisposable : IDisposable + [Fact] + [Trait("Qualification", "Q43")] + public void DrainAggregatesTargetSelectionAndResourceCleanupFailures() + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + InvalidOperationException targetFailure = new("target-bound lease cleanup"); + InvalidOperationException activationFailure = new("activation resource cleanup"); + RecordingDisposable targetBound = new(targetFailure); + RecordingDisposable activationBound = new(activationFailure); + RecordingDisposable healthy = new(); + lifetime.TargetSelection.Track(targetBound, lifetime.TargetSelection.Epoch); + lifetime.Track(activationBound); + lifetime.Track(healthy); + context.Stop(); + + AggregateException exception; + using (lifetime.EnterCleanupScope()) + { + exception = Assert.Throws(lifetime.DrainOwnedResourcesForDisable); + } + + Assert.Collection( + exception.InnerExceptions, + first => Assert.Same(targetFailure, first), + second => Assert.Same(activationFailure, second)); + Assert.Equal(1, targetBound.DisposeCount); + Assert.Equal(1, activationBound.DisposeCount); + Assert.Equal(1, healthy.DisposeCount); + lifetime.Dispose(); + Assert.Equal(1, healthy.DisposeCount); + } + + private sealed class RecordingDisposable(Exception? failure = null) : IDisposable { internal int DisposeCount { @@ -98,6 +140,10 @@ internal int DisposeCount public void Dispose() { DisposeCount++; + if (failure is not null) + { + throw failure; + } } } } diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs index 7ee41a7..51d8b13 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs @@ -8,8 +8,8 @@ public sealed class CoreLifetimeTests [Fact] public void CaptureRejectsClientConstructionOutsideAnEnabledPluginEpoch() { - CheatEngineClientLifecycleException exception = - Assert.Throws(CoreLifetime.Capture); + CheatEngineInvalidStateException exception = + Assert.Throws(CoreLifetime.Capture); Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); Assert.Equal("Client.Activate", exception.Failure.Operation); diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs index d49bf21..6f55acd 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs @@ -9,7 +9,7 @@ public sealed class CoreResourceRegistryTests [Fact] public void DisposeReleasesDistinctResourcesInReverseRegistrationOrderAndIsIdempotent() { - List events = new(); + List events = []; RecordingDisposable first = new("first", events); RecordingDisposable second = new("second", events); RecordingDisposable third = new("third", events); @@ -130,6 +130,71 @@ public void DisposeTargetSelectionContinuesAfterFailureAndPreservesTheFirstExcep Assert.Equal(1, last.DisposeCount); } + [Fact] + [Trait("Qualification", "Q43")] + public void DisposeAggregatesEveryCleanupFailureInReverseOrder() + { + List events = []; + InvalidOperationException firstFailure = new("first module cleanup"); + InvalidOperationException lastFailure = new("last module cleanup"); + RecordingDisposable first = new("first", events, firstFailure); + RecordingDisposable middle = new("middle", events); + RecordingDisposable last = new("last", events, lastFailure); + CoreResourceRegistry registry = new(); + registry.Track(first); + registry.Track(middle); + registry.Track(last); + + AggregateException exception = Assert.Throws(registry.Dispose); + + Assert.Collection( + exception.InnerExceptions, + attemptedFirst => Assert.Same(lastFailure, attemptedFirst), + attemptedLast => Assert.Same(firstFailure, attemptedLast)); + Assert.Equal(["last", "middle", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, middle.DisposeCount); + Assert.Equal(1, last.DisposeCount); + registry.Dispose(); + Assert.Equal(1, first.DisposeCount); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void DisposeRethrowsASingleCleanupFailureAsTheSameInstance() + { + InvalidOperationException failure = new("single cleanup failure"); + CoreResourceRegistry registry = new(); + registry.Track(new RecordingDisposable("ok", [])); + registry.Track(new RecordingDisposable("failing", [], failure)); + + InvalidOperationException exception = Assert.Throws(registry.Dispose); + + Assert.Same(failure, exception); + Assert.Contains(nameof(RecordingDisposable), exception.StackTrace, StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void DisposeTargetSelectionAggregatesEveryCleanupFailure() + { + List events = []; + InvalidOperationException firstFailure = new("first"); + InvalidOperationException secondFailure = new("second"); + CoreResourceRegistry registry = new(); + registry.Track(new RecordingDisposable("first", events, firstFailure), 3); + registry.Track(new RecordingDisposable("unrelated", events), 4); + registry.Track(new RecordingDisposable("second", events, secondFailure), 3); + + AggregateException exception = Assert.Throws(() => registry.DisposeTargetSelection(3)); + + Assert.Collection( + exception.InnerExceptions, + attemptedFirst => Assert.Same(secondFailure, attemptedFirst), + attemptedLast => Assert.Same(firstFailure, attemptedLast)); + Assert.Equal(["second", "first"], events); + } + private sealed class RecordingDisposable(string name, List events, Exception? exception = null) : IDisposable { diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/HostEffectMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/HostEffectMappingTests.cs new file mode 100644 index 0000000..cec55ee --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/HostEffectMappingTests.cs @@ -0,0 +1,40 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Objects; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class HostEffectMappingTests +{ + [Theory] + [InlineData(EngineEffectState.Unknown, CheatEngineHostEffect.Unknown)] + [InlineData(EngineEffectState.NotStarted, CheatEngineHostEffect.NotStarted)] + [InlineData(EngineEffectState.NotApplied, CheatEngineHostEffect.NotApplied)] + [InlineData(EngineEffectState.Applied, CheatEngineHostEffect.Completed)] + public void EachSdkEffectStateMapsToItsClientHostEffect(EngineEffectState state, CheatEngineHostEffect expected) + { + Assert.Equal(expected, HostEffectMapping.FromSdk(state)); + } + + [Fact] + public void EverySdkEffectStateOfTheConsumedPackageIsMapped() + { + EngineEffectState[] mapped = + [ + EngineEffectState.Unknown, EngineEffectState.NotStarted, EngineEffectState.NotApplied, + EngineEffectState.Applied + ]; + + // A value added by a later CheatEngine.SDK fails here until the mapping and the theory above cover it. + Assert.Equal(mapped.Order(), Enum.GetValues().Order()); + } + + [Fact] + public void AnUnrecognizedSdkEffectStateNeverReadsAsAnEstablishedEffect() + { + EngineEffectState unrecognized = (EngineEffectState) (Enum.GetValues().Max(static state => + (int) state) + 1); + + Assert.Equal(CheatEngineHostEffect.Unknown, HostEffectMapping.FromSdk(unrecognized)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/HostResourceLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/HostResourceLeaseTests.cs new file mode 100644 index 0000000..b06cb7c --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/HostResourceLeaseTests.cs @@ -0,0 +1,508 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class HostResourceLeaseTests : IDisposable +{ + private const string Operation = "Test.Release"; + + private static readonly LeaseReleaseOutcome Released = new(LeaseReleaseKind.Released, + CheatEngineHostEffect.Completed); + + private static readonly LeaseReleaseOutcome Unavailable = new(LeaseReleaseKind.CleanupUnavailable, + CheatEngineHostEffect.NotStarted); + + private static readonly LeaseReleaseOutcome Unconfirmed = new(LeaseReleaseKind.CleanupUnconfirmed, + CheatEngineHostEffect.Started); + + private static readonly LeaseReleaseOutcome TargetChanged = new(LeaseReleaseKind.RefusedTargetChanged, + CheatEngineHostEffect.NotStarted); + + private readonly ControlledCoreLifetimeContext _context = new(); + private readonly RecordingDiagnostics _diagnostics = new(); + private readonly MarkingMainThreadInvoker _invoker = new(); + private readonly CoreLifetime _lifetime; + private readonly SdkMainThreadDispatcher _dispatcher; + + public HostResourceLeaseTests() + { + _diagnostics.Invoker = _invoker; + _lifetime = new CoreLifetime(_context, _diagnostics); + _dispatcher = new SdkMainThreadDispatcher(_lifetime, _invoker); + } + + public void Dispose() + { + _context.Dispose(); + } + + [Fact] + public void ReleaseRunsOnTheMainThreadThroughTheDispatcher() + { + ScriptedLease lease = CreateLease(Released); + + LeaseReleaseOutcome outcome = lease.Release(); + + Assert.Equal(Released, outcome); + Assert.Equal([true], lease.RanOnMainThread); + Assert.Equal(1, _invoker.Calls); + Assert.True(lease.IsReleased); + Assert.Equal(Released, lease.LastReleaseOutcome); + } + + [Fact] + public void ReleaseIsIdempotentAndKeepsTheOutcomeThatEndedTheLease() + { + ScriptedLease lease = CreateLease(Released); + + _ = lease.Release(); + LeaseReleaseOutcome repeated = lease.Release(); + lease.Dispose(); + + Assert.Equal(Released, repeated); + Assert.Equal(1, lease.Calls); + Assert.Equal(1, _invoker.Calls); + Assert.Equal(Released, lease.LastReleaseOutcome); + } + + [Fact] + public void ANewLeaseHasNoOutcomeAndIsNotReleased() + { + ScriptedLease lease = CreateLease(Released); + + Assert.False(lease.IsReleased); + Assert.Null(lease.LastReleaseOutcome); + Assert.Equal(0, lease.Calls); + } + + [Fact] + public void DisposeNeverThrowsWhenTheReleaseFaultsAndTheFaultIsNeverRetried() + { + ScriptedLease lease = CreateLease(Released); + lease.Fault = new InvalidOperationException("SDK fault during destroy"); + + lease.Dispose(); + LeaseReleaseOutcome repeated = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + lease.LastReleaseOutcome); + Assert.True(lease.IsReleased); + Assert.Equal(lease.LastReleaseOutcome, repeated); + Assert.Equal(1, lease.Calls); + } + + [Fact] + public void DisposeNeverThrowsWhenDispatchIsRefusedAndTheLeaseStaysActive() + { + ScriptedLease lease = CreateLease(Released); + _context.Stop(); + + lease.Dispose(); + + Assert.Equal(Unavailable, lease.LastReleaseOutcome); + Assert.False(lease.IsReleased); + Assert.Equal(0, lease.Calls); + Assert.Equal(0, _invoker.Calls); + } + + [Fact] + public void ARetryableOutcomeKeepsTheLeaseActiveAndALaterReleaseRetries() + { + ScriptedLease lease = CreateLease(Unavailable, Released); + + LeaseReleaseOutcome first = lease.Release(); + bool releasedAfterFirst = lease.IsReleased; + LeaseReleaseOutcome second = lease.Release(); + + Assert.Equal(Unavailable, first); + Assert.False(releasedAfterFirst); + Assert.Equal(Released, second); + Assert.True(lease.IsReleased); + Assert.Equal(2, lease.Calls); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void DeactivationRetriesRetryableLeasesAndReportsEveryIncompleteOutcomeInOneAggregate() + { + ScriptedLease unconfirmed = CreateLease(Unconfirmed); + ScriptedLease unavailable = CreateLease(Unavailable, Unavailable); + ScriptedLease forgotten = CreateLease(Released); + ScriptedLease released = CreateLease(Released); + ScriptedLease refused = CreateLease(TargetChanged); + unconfirmed.Register(_lifetime); + unavailable.Register(_lifetime); + forgotten.Register(_lifetime); + released.Register(_lifetime); + refused.Register(_lifetime); + + _ = unconfirmed.Release(); + _ = unavailable.Release(); + released.Dispose(); + AggregateException report = Drain(); + + // The drain runs in reverse creation order; complete leases are never reported. + CheatEngineFailure[] failures = + [ + .. report.InnerExceptions.Select(static exception => + Assert.IsType(exception).Failure) + ]; + Assert.Equal( + [ + CheatEngineFailureKind.TargetChanged, CheatEngineFailureKind.CapabilityUnavailable, + CheatEngineFailureKind.IndeterminateHostResult + ], + failures.Select(static failure => failure.Kind)); + Assert.All(failures, static failure => + { + Assert.Equal(Operation, failure.Operation); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Null(failure.Exception); + }); + Assert.Equal( + "The lease release ended with CleanupUnavailable (host effect: NotStarted); the resource may remain in " + + "Cheat Engine or in the target.", failures[1].Message); + Assert.Equal(1, unconfirmed.Calls); + Assert.Equal(2, unavailable.Calls); + Assert.Equal(1, forgotten.Calls); + Assert.Equal(1, released.Calls); + Assert.Equal(1, refused.Calls); + Assert.Equal(Released, forgotten.LastReleaseOutcome); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void ATargetChangeReleasesATargetBoundLeaseWithoutThrowingAndTheDeactivationReportsIt() + { + ScriptedLease lease = CreateLease(TargetChanged); + lease.Register(_lifetime, _lifetime.TargetSelection.Epoch); + + long next = _lifetime.TargetSelection.Advance("Test.SelectTarget"); + + Assert.Equal(1, next); + Assert.True(lease.IsReleased); + Assert.Equal(TargetChanged, lease.LastReleaseOutcome); + CheatEngineOperationException report = Drain(); + Assert.Equal(CheatEngineFailureKind.TargetChanged, report.Failure.Kind); + Assert.Equal(1, lease.Calls); + } + + [Fact] + public void ACompleteTargetBoundReleaseLeavesBothRegistries() + { + ScriptedLease lease = CreateLease(Released); + lease.Register(_lifetime, _lifetime.TargetSelection.Epoch); + + _ = lease.Release(); + _ = _lifetime.TargetSelection.Advance("Test.SelectTarget"); + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + _lifetime.DrainOwnedResourcesForDisable(); + } + + Assert.Equal(1, lease.Calls); + Assert.Equal(Released, lease.LastReleaseOutcome); + } + + [Fact] + public void RegistrationForAnExpiredTargetSelectionFailsAndTracksNothing() + { + ScriptedLease lease = CreateLease(Released); + long staleEpoch = _lifetime.TargetSelection.Epoch; + _ = _lifetime.TargetSelection.Advance("Test.SelectTarget"); + + CheatEngineInvalidStateException exception = + Assert.Throws(() => lease.Register(_lifetime, staleEpoch)); + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + _lifetime.DrainOwnedResourcesForDisable(); + } + + Assert.Equal("Client.TrackResource", exception.Failure.Operation); + Assert.Equal(0, lease.Calls); + } + + [Fact] + public void RegisteringALeaseTwiceIsRejected() + { + ScriptedLease lease = CreateLease(Released); + lease.Register(_lifetime); + + Assert.Throws(() => lease.Register(_lifetime)); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void DisposingTheActivationWithoutACleanupScopeReportsTheLeaseItCouldNotRelease() + { + ScriptedLease lease = CreateLease(Released); + lease.Register(_lifetime); + + CheatEngineOperationException report = Assert.Throws(_lifetime.Dispose); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, report.Failure.Kind); + Assert.Equal(Unavailable, lease.LastReleaseOutcome); + Assert.Equal(0, lease.Calls); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void EveryAttemptIsLoggedWithTheOperationKindAndEffectOnly() + { + ScriptedLease lease = CreateLease(Unavailable, Released); + + _ = lease.Release(); + _ = lease.Release(); + lease.Dispose(); + + // The release of the ended lease is not an attempt: it returns the ending outcome and logs nothing. + Assert.Equal( + [ + (Operation, LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + (Operation, LeaseReleaseKind.Released, CheatEngineHostEffect.Completed) + ], + _diagnostics.Releases); + Assert.False(_diagnostics.LoggedInsideCallback); + } + + /// + /// A lease that ended without a complete release keeps reporting that outcome: a repeated release or dispose returns + /// it unchanged, makes no Cheat Engine call and logs nothing, so it never reads as complete the second time. + /// + [Theory] + [InlineData(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted)] + [InlineData(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown)] + [InlineData(LeaseReleaseKind.PartiallyReleased, CheatEngineHostEffect.Started)] + public void ARepeatedReleaseOfAnIncompleteLeaseReturnsTheEndingOutcome(LeaseReleaseKind kind, + CheatEngineHostEffect hostEffect) + { + LeaseReleaseOutcome ending = new(kind, hostEffect); + ScriptedLease lease = CreateLease(ending); + + LeaseReleaseOutcome first = lease.Release(); + LeaseReleaseOutcome repeated = lease.Release(); + lease.Dispose(); + + Assert.Equal(ending, first); + Assert.Equal(ending, repeated); + Assert.False(repeated.IsComplete); + Assert.True(repeated.RequiresManualRecovery); + Assert.Equal(ending, lease.LastReleaseOutcome); + Assert.Equal(1, lease.Calls); + Assert.Equal(1, _invoker.Calls); + Assert.Equal([(Operation, kind, hostEffect)], _diagnostics.Releases); + } + + /// + /// Every lease reports whether what it owns may remain and no later release can remove it: false before a release, + /// after a complete one and after a retryable one, true once an ending outcome requires manual recovery. + /// + [Theory] + [InlineData(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed, false)] + [InlineData(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted, false)] + [InlineData(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted, true)] + [InlineData(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started, true)] + [InlineData(LeaseReleaseKind.PartiallyReleased, CheatEngineHostEffect.Started, true)] + public void RequiresManualRecoveryFollowsTheOutcomeThatEndedTheLease(LeaseReleaseKind kind, + CheatEngineHostEffect hostEffect, bool expected) + { + ScriptedLease lease = CreateLease(new LeaseReleaseOutcome(kind, hostEffect)); + ICheatEngineLease contract = lease; + + bool before = contract.RequiresManualRecovery; + _ = lease.Release(); + + Assert.False(before); + Assert.Equal(expected, contract.RequiresManualRecovery); + } + + private ScriptedLease CreateLease(params LeaseReleaseOutcome[] outcomes) + { + return new ScriptedLease(_dispatcher, _diagnostics, _invoker, outcomes); + } + + private TException Drain() + where TException : Exception + { + _context.Stop(); + using (_lifetime.EnterCleanupScope()) + { + return Assert.Throws(_lifetime.DrainOwnedResourcesForDisable); + } + } + + /// Returns scripted outcomes in order and records whether each release ran inside the main-thread invoker. + private sealed class ScriptedLease( + ICheatEngineDispatcher dispatcher, + ICoreDiagnostics diagnostics, + MarkingMainThreadInvoker invoker, + LeaseReleaseOutcome[] outcomes) : HostResourceLease(HostResourceLeaseTests.Operation, dispatcher, diagnostics) + { + private int _next; + + internal int Calls + { + get; + private set; + } + + internal List RanOnMainThread + { + get; + } = []; + + internal Exception? Fault + { + get; + set; + } + + protected override LeaseReleaseOutcome ReleaseOnMainThread() + { + Calls++; + RanOnMainThread.Add(invoker.IsInvoking); + if (Fault is not null) + { + throw Fault; + } + + LeaseReleaseOutcome outcome = outcomes[Math.Min(_next, outcomes.Length - 1)]; + _next++; + return outcome; + } + } + + /// Runs callbacks inline and marks the time spent inside them as the main thread. + private sealed class MarkingMainThreadInvoker : IMainThreadInvoker + { + internal int Calls + { + get; + private set; + } + + internal bool IsInvoking + { + get; + private set; + } + + public Exception? Invoke(Action callback) + { + return Invoke(() => + { + callback(); + return true; + }).Exception; + } + + public MainThreadInvocationResult Invoke(Func callback) + { + Calls++; + IsInvoking = true; + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + finally + { + IsInvoking = false; + } + } + } + + /// Records the lease events and whether one was emitted inside a dispatched callback. + private sealed class RecordingDiagnostics : ICoreDiagnostics + { + internal MarkingMainThreadInvoker? Invoker + { + get; + set; + } + + internal List<(string Operation, LeaseReleaseKind Kind, CheatEngineHostEffect HostEffect)> Releases + { + get; + } = []; + + internal bool LoggedInsideCallback + { + get; + private set; + } + + public void LeaseReleased(string operation, LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + LoggedInsideCallback |= Invoker?.IsInvoking == true; + Releases.Add((operation, kind, hostEffect)); + } + + public void RuntimeSnapshotCaptured(long activationEpoch, CheatEngineArchitecture targetArchitecture, + int processPointerBytes, int configuredPointerBytes, bool pointerSizeMismatch) + { + } + + public void CapabilityRefused(string capability, string operation, ClientCapabilityEvidenceReasonCode gate, + ClientCapabilityEvidenceState gateState) + { + } + + public void TargetSelectionAdvanced(long activationEpoch, long selectionEpoch, string operation, string reason) + { + } + + public void PointerWidthMismatchRefused(string operation, int processPointerBytes, int configuredPointerBytes) + { + } + + public void MemoryBatchCompleted(string operation, int requested, int completed, string effectState) + { + } + + public void TableGenerationAdvanced(long activationEpoch, long tableGeneration) + { + } + + public void StaleRecordIdentifierRefused(string operation, long tableGeneration) + { + } + + public void RecordActivationNotApplied(string operation, bool requestedState, string status) + { + } + + public void SymbolRegistrationRejected(string operation, string reason) + { + } + + public void PatternScanCompleted(PatternScanScope scope, long hostResultCount, int materializedCount, + bool truncated, long hostScanMilliseconds, long copyMilliseconds) + { + } + + public void LuaOperationCompleted(string operation, string outcome, long elapsedMilliseconds, int scriptLength) + { + } + + public void CoreResourceCleanupFailed(string componentType, string exceptionType) + { + } + + public void AutoAssemblerPatchAppliedAfterTargetChange(string operation, long selectionEpoch) + { + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/LuaAdmissionTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/LuaAdmissionTests.cs new file mode 100644 index 0000000..cfae0dd --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/LuaAdmissionTests.cs @@ -0,0 +1,81 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +/// +/// Proves the Lua admission classification: only an admitted operation succeeds, and every refusal is reported from +/// the SDK's admission status with , never as a rejection. +/// +/// +/// is a static SDK class that cannot be faked; the classification seam +/// () is exercised for every status, and the real SDK call is exercised in the +/// one state a unit test can reach: no Lua runtime attached. +/// +public sealed class LuaAdmissionTests +{ + [Theory] + [InlineData(LuaAdmissionStatus.Detached, CheatEngineFailureKind.ActivationExpired)] + [InlineData(LuaAdmissionStatus.TransitionInProgress, CheatEngineFailureKind.ActivationExpired)] + [InlineData(LuaAdmissionStatus.ExternalStateReset, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(LuaAdmissionStatus.ThreadNotAdmitted, CheatEngineFailureKind.InvalidState)] + [InlineData(LuaAdmissionStatus.NoStateForThread, CheatEngineFailureKind.InvalidState)] + [InlineData(LuaAdmissionStatus.Unknown, CheatEngineFailureKind.IndeterminateHostResult)] + [InlineData((LuaAdmissionStatus) 99, CheatEngineFailureKind.IndeterminateHostResult)] + public void EachRefusalIsClassifiedAsNotStarted(LuaAdmissionStatus status, CheatEngineFailureKind expectedKind) + { + bool admitted = LuaAdmission.TryClassify(status, "UnsafeLua.Execute", out CheatEngineFailure failure); + + Assert.False(admitted); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("UnsafeLua.Execute", failure.Operation); + Assert.Null(failure.Exception); + Assert.NotEqual(CheatEngineFailureKind.OperationRejected, failure.Kind); + } + + [Theory] + [InlineData(LuaAdmissionStatus.ThreadNotAdmitted)] + [InlineData(LuaAdmissionStatus.NoStateForThread)] + public void AnOffMainThreadRefusalIsReportedAsAClientBug(LuaAdmissionStatus status) + { + Assert.False(LuaAdmission.TryClassify(status, "UnsafeLua.Execute", out CheatEngineFailure failure)); + + Assert.StartsWith("Client bug: called off the main thread.", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void OnlyAdmittedIsASuccess() + { + Assert.True(LuaAdmission.TryClassify(LuaAdmissionStatus.Admitted, "UnsafeLua.Execute", + out CheatEngineFailure failure)); + Assert.Equal(default, failure); + } + + [Fact] + public void TryAcquireReportsADetachedRuntimeAsAnExpiredActivation() + { + // No Lua runtime is attached in unit tests: the real SDK admission reports Detached. + bool admitted = LuaAdmission.TryAcquire("UnsafeLua.Execute", out LuaRuntimeOperation operation, + out CheatEngineFailure failure); + operation.Dispose(); + + Assert.False(admitted); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("UnsafeLua.Execute", failure.Operation); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TryAcquireRequiresAnOperationName(string operation) + { + Assert.Throws(() => + { + _ = LuaAdmission.TryAcquire(operation, out LuaRuntimeOperation admitted, out _); + admitted.Dispose(); + }); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/OwnershipHandoffTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/OwnershipHandoffTests.cs new file mode 100644 index 0000000..ffd43d2 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/OwnershipHandoffTests.cs @@ -0,0 +1,160 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +/// Proves the single release authority between SDK owner acquisition and Client publication (audit F13). +public sealed class OwnershipHandoffTests +{ + [Theory] + [InlineData(nameof(OutOfMemoryException))] + [InlineData(nameof(InvalidOperationException))] + [SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", + Justification = "Simulates the allocation failure named by audit F13 inside a test publisher only.")] + public void AuthorityTransferWithInjectedFailureAfterAcquisitionReleasesTheOwnerExactlyOnce(string failureType) + { + Exception publicationFailure = failureType == nameof(OutOfMemoryException) + ? new OutOfMemoryException("simulated allocation failure while publishing the wrapper") + : new InvalidOperationException("simulated wrapper constructor validation failure"); + CountingOwner owner = new(); + + Exception thrown = Assert.ThrowsAny(() => + OwnershipHandoff.Adopt(owner, _ => throw publicationFailure, CountingOwner.Release)); + + Assert.Same(publicationFailure, thrown); + Assert.Equal(1, owner.ReleaseCount); + } + + [Fact] + public void AuthorityTransferWithInjectedFailureAndThrowingReleaseReportsBothFailures() + { + InvalidOperationException publicationFailure = new("publication failed"); + InvalidOperationException releaseFailure = new("release failed"); + CountingOwner owner = new(releaseFailure: releaseFailure); + + OwnershipHandoffException exception = Assert.Throws(() => + OwnershipHandoff.Adopt(owner, _ => throw publicationFailure, CountingOwner.Release)); + + Assert.Collection( + exception.InnerExceptions, + first => Assert.Same(publicationFailure, first), + second => Assert.Same(releaseFailure, second)); + Assert.Equal(LeaseReleaseKind.Unknown, exception.ReleaseKind); + Assert.Same(publicationFailure, exception.PublishFailure); + Assert.Same(releaseFailure, exception.ReleaseFailure); + Assert.Equal(1, owner.ReleaseCount); + } + + /// + /// The SDK's ReleaseWithOutcome never throws: an unconfirmed release is an outcome, which the handoff + /// reports with the publication failure instead of hiding it. + /// + [Theory] + [InlineData(TargetReleaseStatus.UnconfirmedAfterInvocation, LeaseReleaseKind.CleanupUnconfirmed)] + [InlineData(TargetReleaseStatus.NotInvoked, LeaseReleaseKind.CleanupUnavailable)] + [InlineData(TargetReleaseStatus.RefusedRuntimeChanged, LeaseReleaseKind.RefusedRuntimeChanged)] + public void AuthorityTransferWithInjectedFailureAndUnconfirmedReleaseReportsTheReleaseKind( + TargetReleaseStatus status, LeaseReleaseKind expectedKind) + { + InvalidOperationException publicationFailure = new("publication failed"); + CountingOwner owner = new(status); + + OwnershipHandoffException exception = Assert.Throws(() => + OwnershipHandoff.Adopt(owner, _ => throw publicationFailure, CountingOwner.Release)); + + Assert.Same(publicationFailure, Assert.Single(exception.InnerExceptions)); + Assert.Equal(expectedKind, exception.ReleaseKind); + Assert.Null(exception.ReleaseFailure); + Assert.Equal(1, owner.ReleaseCount); + } + + [Fact] + public void AuthorityTransferOnSuccessNeverReleasesTheOwner() + { + CountingOwner owner = new(); + + PublishedWrapper wrapper = OwnershipHandoff.Adopt(owner, static acquired => new PublishedWrapper(acquired), + CountingOwner.Release); + + Assert.Same(owner, wrapper.Owner); + Assert.Equal(0, owner.ReleaseCount); + } + + [Fact] + public void AuthorityTransferWithoutAPublisherReleasesTheOwnerOnce() + { + CountingOwner owner = new(); + + Assert.Throws(() => + OwnershipHandoff.Adopt(owner, null!, CountingOwner.Release)); + + Assert.Equal(1, owner.ReleaseCount); + } + + [Fact] + public void AuthorityTransferRejectsAMissingOwner() + { + Assert.Throws(() => + OwnershipHandoff.Adopt(null!, static _ => new object(), CountingOwner.Release)); + } + + [Fact] + public void AuthorityTransferRejectsAMissingReleaseBeforePublishing() + { + CountingOwner owner = new(); + bool published = false; + + Assert.Throws(() => OwnershipHandoff.Adopt(owner, _ => + { + published = true; + return new object(); + }, null!)); + + Assert.False(published); + Assert.Equal(0, owner.ReleaseCount); + } + + private sealed class CountingOwner( + TargetReleaseStatus status = TargetReleaseStatus.Released, + Exception? releaseFailure = null) + { + internal int ReleaseCount + { + get; + private set; + } + + /// Releases like the SDK adapter: one release, mapped through . + internal static LeaseReleaseOutcome Release(CountingOwner owner) + { + owner.ReleaseCount++; + if (owner.ReleaseFailure is not null) + { + throw owner.ReleaseFailure; + } + + return SdkReleaseOutcomes.FromTarget(owner.Status); + } + + private TargetReleaseStatus Status + { + get; + } = status; + + private Exception? ReleaseFailure + { + get; + } = releaseFailure; + } + + private sealed class PublishedWrapper(CountingOwner owner) + { + internal CountingOwner Owner + { + get; + } = owner; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/SdkReleaseOutcomesTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/SdkReleaseOutcomesTests.cs new file mode 100644 index 0000000..563a28e --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/SdkReleaseOutcomesTests.cs @@ -0,0 +1,166 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class SdkReleaseOutcomesTests +{ + private static readonly LeaseReleaseOutcome UnrecognizedOutcome = + new(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown); + + private static readonly Dictionary TargetTable = new() + { + [TargetReleaseStatus.Unspecified] = Outcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.Released] = Outcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + [TargetReleaseStatus.RefusedNoTarget] = + Outcome(LeaseReleaseKind.RefusedTargetNotAttached, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedIdentityUnavailable] = + Outcome(LeaseReleaseKind.RefusedTargetIdentityUnavailable, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedTargetChanged] = + Outcome(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedProcessReused] = + Outcome(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.UnconfirmedAfterInvocation] = + Outcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + [TargetReleaseStatus.NotInvoked] = + Outcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + [TargetReleaseStatus.RefusedRuntimeChanged] = + Outcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted) + }; + + private static readonly Dictionary SymbolTable = new() + { + [SymbolRegistrationReleaseKind.Unknown] = UnrecognizedOutcome, + [SymbolRegistrationReleaseKind.Released] = Outcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + [SymbolRegistrationReleaseKind.AlreadyReleased] = + Outcome(LeaseReleaseKind.AlreadyReleased, CheatEngineHostEffect.NotStarted), + [SymbolRegistrationReleaseKind.Superseded] = + Outcome(LeaseReleaseKind.Superseded, CheatEngineHostEffect.NotStarted), + [SymbolRegistrationReleaseKind.StaleRuntime] = + Outcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + [SymbolRegistrationReleaseKind.CleanupUnavailable] = + Outcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + [SymbolRegistrationReleaseKind.CleanupIndeterminate] = + Outcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + [SymbolRegistrationReleaseKind.Replaced] = Outcome(LeaseReleaseKind.Replaced, CheatEngineHostEffect.NotStarted), + [SymbolRegistrationReleaseKind.ExternallyRemoved] = + Outcome(LeaseReleaseKind.ExternallyRemoved, CheatEngineHostEffect.NotStarted) + }; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryTargetReleaseStatusIsMappedAndAnUnknownStatusFailsClosed() + { + MappingTotality.AssertTotal( + static status => TargetTable.TryGetValue(status, out LeaseReleaseOutcome expected) && + SdkReleaseOutcomes.FromTarget(status) == expected, + static status => SdkReleaseOutcomes.FromTarget(status) == UnrecognizedOutcome); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EverySymbolRegistrationReleaseKindIsMappedAndAnUnknownKindFailsClosed() + { + MappingTotality.AssertTotal( + static kind => SymbolTable.TryGetValue(kind, out LeaseReleaseOutcome expected) && + SdkReleaseOutcomes.FromSymbolRegistration(kind) == expected, + static kind => SdkReleaseOutcomes.FromSymbolRegistration(kind) == UnrecognizedOutcome); + } + + /// The three SDK statuses the plan names keep their documented Client counterparts. + [Fact] + public void ProcessReuseUnconfirmedAndNotInvokedKeepTheirDocumentedMeaning() + { + LeaseReleaseOutcome reused = SdkReleaseOutcomes.FromTarget(TargetReleaseStatus.RefusedProcessReused); + LeaseReleaseOutcome unconfirmed = SdkReleaseOutcomes.FromTarget(TargetReleaseStatus.UnconfirmedAfterInvocation); + LeaseReleaseOutcome notInvoked = SdkReleaseOutcomes.FromTarget(TargetReleaseStatus.NotInvoked); + + Assert.Equal(LeaseReleaseKind.RefusedTargetChanged, reused.Kind); + Assert.True(reused.RequiresManualRecovery); + Assert.Equal(Outcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), unconfirmed); + Assert.True(unconfirmed.RequiresManualRecovery); + Assert.Equal(Outcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), notInvoked); + Assert.True(notInvoked.IsRetryable); + } + + /// No SDK status reads as a release unless the SDK confirmed one. + [Fact] + public void OnlyConfirmedSdkReleasesMapToReleased() + { + Assert.Equal([TargetReleaseStatus.Released], TargetTable + .Where(static pair => pair.Value.Kind == LeaseReleaseKind.Released).Select(static pair => pair.Key)); + Assert.Equal([SymbolRegistrationReleaseKind.Released], SymbolTable + .Where(static pair => pair.Value.Kind == LeaseReleaseKind.Released).Select(static pair => pair.Key)); + } + + /// The combinator is commutative and keeps the kind that leaves the most to do. + [Theory] + [InlineData(LeaseReleaseKind.Released, LeaseReleaseKind.CleanupUnconfirmed, LeaseReleaseKind.CleanupUnconfirmed)] + [InlineData(LeaseReleaseKind.Released, LeaseReleaseKind.Replaced, LeaseReleaseKind.Replaced)] + [InlineData(LeaseReleaseKind.AlreadyReleased, LeaseReleaseKind.Released, LeaseReleaseKind.AlreadyReleased)] + [InlineData(LeaseReleaseKind.RefusedTargetChanged, LeaseReleaseKind.PartiallyReleased, + LeaseReleaseKind.PartiallyReleased)] + [InlineData(LeaseReleaseKind.RefusedRuntimeChanged, LeaseReleaseKind.RefusedTargetNotAttached, + LeaseReleaseKind.RefusedRuntimeChanged)] + [InlineData(LeaseReleaseKind.CleanupUnconfirmed, LeaseReleaseKind.CleanupUnavailable, + LeaseReleaseKind.CleanupUnavailable)] + [InlineData(LeaseReleaseKind.CleanupUnavailable, LeaseReleaseKind.Unknown, LeaseReleaseKind.Unknown)] + [InlineData(LeaseReleaseKind.ExternallyRemoved, LeaseReleaseKind.RefusedTargetNotAttached, + LeaseReleaseKind.RefusedTargetNotAttached)] + public void WorstKeepsTheKindThatLeavesTheMostToDo(LeaseReleaseKind first, LeaseReleaseKind second, + LeaseReleaseKind expected) + { + LeaseReleaseOutcome a = Outcome(first, CheatEngineHostEffect.NotStarted); + LeaseReleaseOutcome b = Outcome(second, CheatEngineHostEffect.NotStarted); + + Assert.Equal(expected, SdkReleaseOutcomes.Worst(a, b).Kind); + Assert.Equal(expected, SdkReleaseOutcomes.Worst(b, a).Kind); + } + + /// A retryable part keeps the whole lease retryable, so the part that can still be released is retried. + [Fact] + public void AnyRetryablePartKeepsTheCombinedOutcomeRetryable() + { + LeaseReleaseKind[] kinds = Enum.GetValues(); + foreach (LeaseReleaseKind first in kinds) + { + foreach (LeaseReleaseKind second in kinds) + { + LeaseReleaseOutcome a = Outcome(first, CheatEngineHostEffect.Completed); + LeaseReleaseOutcome b = Outcome(second, CheatEngineHostEffect.Completed); + LeaseReleaseOutcome combined = SdkReleaseOutcomes.Worst(a, b); + + Assert.Equal(combined, SdkReleaseOutcomes.Worst(b, a)); + Assert.Equal(a.IsRetryable || b.IsRetryable, combined.IsRetryable); + Assert.Equal(a.IsComplete && b.IsComplete, combined.IsComplete); + } + } + } + + /// Host effects combine separately: equal stays, Unknown wins, then CleanupUnconfirmed, else Started. + [Theory] + [InlineData(CheatEngineHostEffect.Completed, CheatEngineHostEffect.Completed, CheatEngineHostEffect.Completed)] + [InlineData(CheatEngineHostEffect.NotStarted, CheatEngineHostEffect.NotStarted, CheatEngineHostEffect.NotStarted)] + [InlineData(CheatEngineHostEffect.Completed, CheatEngineHostEffect.NotStarted, CheatEngineHostEffect.Started)] + [InlineData(CheatEngineHostEffect.Started, CheatEngineHostEffect.Completed, CheatEngineHostEffect.Started)] + [InlineData(CheatEngineHostEffect.Unknown, CheatEngineHostEffect.CleanupUnconfirmed, CheatEngineHostEffect.Unknown)] + [InlineData(CheatEngineHostEffect.CleanupUnconfirmed, CheatEngineHostEffect.Completed, + CheatEngineHostEffect.CleanupUnconfirmed)] + public void WorstCombinesHostEffectsSeparately(CheatEngineHostEffect first, CheatEngineHostEffect second, + CheatEngineHostEffect expected) + { + LeaseReleaseOutcome a = Outcome(LeaseReleaseKind.Released, first); + LeaseReleaseOutcome b = Outcome(LeaseReleaseKind.CleanupUnconfirmed, second); + + Assert.Equal(expected, SdkReleaseOutcomes.Worst(a, b).HostEffect); + Assert.Equal(expected, SdkReleaseOutcomes.Worst(b, a).HostEffect); + } + + private static LeaseReleaseOutcome Outcome(LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + return new LeaseReleaseOutcome(kind, hostEffect); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs index a306e83..169639b 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs @@ -27,7 +27,7 @@ public void AdvanceInvalidatesOnlyThePreviousSelectionAndDisposesItsResourcesInL Assert.Equal(1, first.DisposeCount); Assert.Equal(1, second.DisposeCount); - CheatEngineClientLifecycleException exception = Assert.Throws(() => + CheatEngineInvalidStateException exception = Assert.Throws(() => lifetime.ThrowIfExpired(firstEpoch, "Memory.Read")); Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); @@ -99,15 +99,15 @@ public void UntrackPreventsTheNextSelectionAdvanceFromDisposingTheReleasedResour [Fact] public void ActivationGuardRejectsAnAdvanceWithoutChangingTheTargetSelectionEpoch() { - CheatEngineActivationExpiredException expected = new("Process.Refresh", "The plugin epoch changed."); TargetSelectionLifetime lifetime = new(static operation => - throw new CheatEngineActivationExpiredException(operation, "The plugin epoch changed.")); + throw new CheatEngineFailure(CheatEngineFailureKind.ActivationExpired, operation, + "The plugin epoch changed.").ToException(TestContext.Current.CancellationToken)); CheatEngineActivationExpiredException exception = Assert.Throws(() => lifetime.Advance("Process.Refresh")); - Assert.Equal(expected.Failure.Kind, exception.Failure.Kind); - Assert.Equal(expected.Failure.Operation, exception.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); + Assert.Equal("Process.Refresh", exception.Failure.Operation); Assert.Equal(0, lifetime.Epoch); } diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TryContractTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TryContractTests.cs new file mode 100644 index 0000000..98565e6 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TryContractTests.cs @@ -0,0 +1,2104 @@ +#pragma warning disable CECLIENT5003 // The Try contract covers the experimental instruction family. +#pragma warning disable CECLIENT5004 // The Try contract covers the experimental Auto Assembler family. + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.Allocations; +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Domains.ValueScanning; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +/// +/// Proves the per-family Try / exception / cancellation contract (audit F15, A10-20, A10-21, A11-31): which refusals +/// become failures, which lifecycle faults are thrown, that no SDK exception crosses a Try method, and the host effect +/// reported when cancellation or a partial result is observed. +/// +/// +/// Every test uses the real with an inline invoker: a fake dispatcher could not +/// prove the same-instance rethrow rule. +/// +public sealed class TryContractTests +{ + private static readonly Address Target = new(0x401000); + + public static TheoryData Families => + [ + "Patterns", + "MemoryPrimitive", + "MemoryBatchDetailed", + "MemoryBytesDetailed", + "Inspection", + "Tables", + "LuaTypedOperation", + "LuaModuleRegistration", + "UnsafeLua", + "Processes", + "Runtime", + "ValueScans", + "Allocations", + "AutoAssembler", + "Instructions" + ]; + + public static TheoryData SdkFaultCases + { + get + { + TheoryData data = []; + foreach (string fault in SdkFaultKinds.Keys) + { + foreach (string entryPoint in SdkFaultEntryPoints) + { + data.Add(fault, entryPoint); + } + } + + return data; + } + } + + /// Gets each entry point that validates an argument, in each activation state. + public static TheoryData ArgumentCases + { + get + { + TheoryData data = []; + foreach (string entryPoint in ArgumentEntryPoints) + { + foreach (string state in ActivationStates) + { + data.Add(entryPoint, state); + } + } + + return data; + } + } + + /// Gets every public operation that needs the activation, on an ended and a stopping activation. + public static TheoryData ActivationRefusalCases + { + get + { + TheoryData data = []; + foreach (string operation in ActivationOperations) + { + data.Add(operation, false); + data.Add(operation, true); + } + + return data; + } + } + + /// Gets each SDK fault type at each scoped-route SDK call that follows module resolution. + public static TheoryData ScopedAobFaultCases + { + get + { + TheoryData data = []; + foreach (string fault in SdkFaultKinds.Keys) + { + data.Add(fault, "ObserveSelection"); + data.Add(fault, "TryScanWithinBounds"); + } + + return data; + } + } + + private static Dictionary SdkFaultKinds => new(StringComparer.Ordinal) + { + [nameof(EngineBindingException)] = CheatEngineFailureKind.BindingError, + [nameof(EngineCapabilityUnavailableException)] = CheatEngineFailureKind.CapabilityUnavailable, + [nameof(EngineGlobalUnavailableException)] = CheatEngineFailureKind.CapabilityUnavailable, + [nameof(EngineLuaException)] = CheatEngineFailureKind.LuaError, + [nameof(EngineMarshallingException)] = CheatEngineFailureKind.InvalidHostResult, + [nameof(EngineOperationFailedException)] = CheatEngineFailureKind.OperationRejected, + [nameof(LuaException)] = CheatEngineFailureKind.LuaError, + [nameof(InvalidOperationException)] = CheatEngineFailureKind.OperationRejected + }; + + private static string[] ActivationStates => ["Active", "Ended", "Stopping"]; + + /// Gets every public operation that needs the activation, by its operation name. + private static string[] ActivationOperations => + [ + "Runtime.GetSnapshot", "Runtime.GetClientCapability", "Dispatcher.Invoke", "Processes.GetCurrentProcess", + "Processes.Refresh", "Processes.Attach", "Processes.AttachExactName", "Memory.ReadPrimitive", + "Memory.WritePrimitive", "Memory.ReadPrimitiveBatch", "Memory.WritePrimitiveBatch", "Memory.ReadBytes", + "Memory.WriteBytes", "Memory.ReadString", "Memory.WriteString", "Memory.ResolvePointerChain", "Memory.Read", + "Memory.Write", "Patterns.Scan", "ValueScans.CreateSession", "ValueScans.FirstScan", "ValueScans.NextScan", + "ValueScans.Reset", "ValueScans.GetResultCount", "ValueScans.Read", "Allocations.Allocate", + "Inspection.GetModules", "Inspection.GetModuleSections", "Inspection.GetMemoryRegions", + "Inspection.GetMemoryRegion", "Inspection.GetSymbol", "Inspection.ResolveName", "Inspection.RegisterSymbol", + "Inspection.ResolveAddress", "Tables.GetRecordCount", "Tables.GetSnapshot", "Tables.Find", + "Tables.GetRecordAt", "Tables.GetRecord", "Tables.GetSelectedRecord", "Tables.SelectRecord", "Tables.Create", + "Tables.Update", "Tables.Delete", "Tables.SetActive", "Tables.SetParent", "Tables.GetHierarchy", + "Tables.LoadTrustedTable", "Tables.SaveTable", "Lua.RegisterModule", "Lua.Execute", "UnsafeLua.Execute", + "AutoAssembler.Check", "AutoAssembler.ApplyPatch", "Assembly.Assemble", "Assembly.Disassemble", + "Assembly.GetInstructionLength", "Assembly.GetPreviousInstructionAddress" + ]; + + private static string[] ArgumentEntryPoints => + [ + "Dispatcher.Invoke", + "Runtime.GetClientCapability", + "Processes.Attach", + "Processes.AttachExactName", + "Memory.ReadBytes", + "Memory.ReadString.UndefinedEncoding", + "Memory.WriteString.UndefinedEncoding", + "Memory.Read", + "Memory.WritePrimitiveBatch", + "Patterns.Scan", + "ValueScans.FirstScan", + "ValueScans.NextScan", + "ValueScans.NextScan.UndefinedValueType", + "ValueScans.Read", + "Allocations.Allocate", + "Inspection.GetModules", + "Inspection.GetSymbol", + "Inspection.RegisterSymbol", + "Tables.Find", + "Tables.Create", + "Tables.Update", + "Tables.GetHierarchy", + "Tables.LoadTrustedTable", + "Lua.RegisterModule", + "UnsafeLua.Execute", + "AutoAssembler.ApplyPatch", + "Assembly.Assemble" + ]; + + private static string[] SdkFaultEntryPoints => + [ + "Patterns.Scan", + "Patterns.Scan.InModule", + "Inspection.GetSymbol", + "Inspection.RegisterSymbol", + "Inspection.ResolveName", + "Tables.GetRecord", + "Tables.Delete", + "Memory.ReadPrimitive", + "Memory.ReadBytes", + "Memory.WritePrimitiveBatch", + "Memory.ResolvePointerChain", + "Processes.GetCurrentProcess", + "Processes.Attach", + "Runtime.GetSnapshot", + "Lua.RegisterModule", + "ValueScans.CreateSession", + "ValueScans.GetResultCount", + "Allocations.Allocate", + "AutoAssembler.ApplyPatch", + "AutoAssembler.Check", + "Assembly.Assemble", + "Assembly.Disassemble", + "Assembly.GetInstructionLength", + "Assembly.GetPreviousInstructionAddress" + ]; + + [Theory] + [MemberData(nameof(Families))] + public void ActivationExpiredExceptionIsNeverMappedToCancelledOrCapabilityUnavailable(string family) + { + using ControlledCoreLifetimeContext context = new() + { + IsCurrent = false + }; + CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + CancellationToken cancelled = new(true); + ThrowingPorts ports = new(new InvalidOperationException("never reached")); + FakeValueScanPort scans = new(); + FakeAllocationPort allocations = new(); + CoreClientPolicy policy = new([], enableUnsafeLuaExecution: true); + + Action entryPoint = family switch + { + "Patterns" => () => new PatternScanner(dispatcher, ports).TryScan(Request(), out _, out _, cancelled), + "MemoryPrimitive" => () => new MemoryClient(dispatcher, lifetime, ports) + .TryReadPrimitive(Target, out int _, out _, cancelled), + "MemoryBatchDetailed" => () => new MemoryClient(dispatcher, lifetime, ports).WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(Target, 1)]), cancelled), + "MemoryBytesDetailed" => () => new MemoryClient(dispatcher, lifetime, ports) + .ReadBytesDetailed(new MemoryBytesReadRequest(Target, 4), cancelled), + "Inspection" => () => new InspectionClient(dispatcher, lifetime, ports) + .TryGetSymbol(new SymbolExpression("game.exe+10"), out _, out _, cancelled), + "Tables" => () => new TableClient(dispatcher, policy, ports, lifetime, ports) + .TryGetRecord(new MemoryRecordId(1), out _, out _, cancelled), + "LuaTypedOperation" => () => new LuaClient(dispatcher, lifetime) + .TryExecute(new ConstantOperation(), out _, out _, cancelled), + "LuaModuleRegistration" => () => new LuaClient(dispatcher, lifetime) + .TryRegisterModule(new PortBackedModule(ports), out _, out _, cancelled), + "UnsafeLua" => () => new UnsafeLuaClient(dispatcher, policy, lifetime) + .TryExecute(new LuaScript("return 1"), out _, cancelled), + "Processes" => () => new ProcessClient(dispatcher, ports, ports, ports, lifetime) + .TryGetCurrentProcess(out _, out _, cancelled), + "Runtime" => () => new RuntimeClient(dispatcher, ports, static () => 1).TryGetSnapshot(out _, out _, cancelled), + "ValueScans" => () => new ValueScanner(dispatcher, Binder(dispatcher), scans) + .TryCreateSession(out _, out _, cancelled), + "Allocations" => () => new AllocationClient(dispatcher, Binder(dispatcher), allocations) + .TryAllocate(new AllocationRequest(4096), out _, out _, cancelled), + "AutoAssembler" => () => new AutoAssemblerClient(dispatcher, AutoAssemblerPolicy(), lifetime, + Binder(dispatcher), ports) + .TryApplyPatch(new AutoAssemblerScript("[ENABLE]"), out _, out _, cancelled), + "Instructions" => () => new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports) + .TryDisassemble(Target, out _, out _, cancelled), + _ => throw new ArgumentOutOfRangeException(nameof(family), family, null) + }; + + CheatEngineActivationExpiredException exception = + Assert.Throws(entryPoint); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); + Assert.Equal(0, ports.Calls); + Assert.Equal(0, scans.Creations); + Assert.Equal(0, allocations.Allocations); + } + + /// + /// A null, default or malformed argument is a programming error, checked first like the BCL checks its + /// arguments: every family throws its argument exception whatever the activation state (active, ended or + /// stopping), before the activation check and before any Cheat Engine call. + /// + [Theory] + [MemberData(nameof(ArgumentCases))] + public void AnInvalidArgumentThrowsBeforeTheActivationIsChecked(string entryPoint, string state) + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + CountingMainThreadInvoker invoker = new(); + SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); + ThrowingPorts ports = new(new InvalidOperationException("never reached")); + FakeValueScanPort scans = new(); + FakeAllocationPort allocations = new(); + CancellationToken token = TestContext.Current.CancellationToken; + IValueScanSession session = new ValueScanner(dispatcher, Binder(dispatcher), scans).CreateSession(token); + MemoryClient memory = new(dispatcher, lifetime, ports); + InspectionClient inspection = new(dispatcher, lifetime, ports); + TableClient tables = new(dispatcher, new CoreClientPolicy([], enableUnsafeLuaExecution: false), ports, + lifetime, ports); + ProcessClient processes = new(dispatcher, ports, ports, ports, lifetime); + MemoryRecordId record = new(7); + Action invalid = entryPoint switch + { + "Dispatcher.Invoke" => () => _ = dispatcher.TryInvoke((Action) null!, out _, token), + "Runtime.GetClientCapability" => () => _ = new RuntimeClient(dispatcher, ports, static () => 1) + .TryGetClientCapability(default, out _, out _, token), + "Processes.Attach" => () => _ = processes.TryAttach(default, out _, out _, token), + "Processes.AttachExactName" => () => _ = processes.TryAttachExactName(" ", out _, out _, token), + "Memory.ReadBytes" => () => _ = memory.TryReadBytes(default, out _, out _, token), + "Memory.ReadString.UndefinedEncoding" => () => _ = memory.TryReadString(TamperedValues.WithBackingField( + new MemoryStringReadRequest(Target, 16, MemoryStringEncoding.Utf8), + nameof(MemoryStringReadRequest.Encoding), (MemoryStringEncoding) 7), out _, out _, token), + "Memory.WriteString.UndefinedEncoding" => () => _ = memory.TryWriteString(TamperedValues.WithBackingField( + new MemoryStringWriteRequest(Target, "a", 16, MemoryStringEncoding.Utf8), + nameof(MemoryStringWriteRequest.Encoding), (MemoryStringEncoding) 7), out _, token), + "Memory.Read" => () => _ = memory.TryRead(default(MemoryReadRequest), out _, out _, token), + "Memory.WritePrimitiveBatch" => () => + _ = memory.WritePrimitiveBatchDetailed(default(MemoryPrimitiveBatchWriteRequest), token), + "Patterns.Scan" => () => _ = new PatternScanner(dispatcher, ports).TryScan(default, out _, out _, token), + "ValueScans.FirstScan" => () => _ = session.TryFirstScan(default, out _, token), + "ValueScans.NextScan" => () => _ = session.TryNextScan(default, out _, token), + "ValueScans.NextScan.UndefinedValueType" => () => _ = session.TryNextScan(TamperedValues.WithBackingField( + ValueScanNextRequest.Exact(ValueScanValue.FromInt32(1)), nameof(ValueScanNextRequest.Value), + (ValueScanValue?) TamperedValues.WithBackingField(ValueScanValue.FromInt32(1), + nameof(ValueScanValue.ValueType), (ValueScanValueType) 99)), out _, token), + "ValueScans.Read" => () => _ = session.TryRead(default, out _, out _, token), + "Allocations.Allocate" => () => _ = new AllocationClient(dispatcher, Binder(dispatcher), allocations) + .TryAllocate(default, out _, out _, token), + "Inspection.GetModules" => () => _ = inspection.TryGetModules(default, null, out _, out _, token), + "Inspection.GetSymbol" => () => _ = inspection.TryGetSymbol(default, out _, out _, token), + "Inspection.RegisterSymbol" => () => _ = inspection.TryRegisterSymbol(default, out _, out _, token), + "Tables.Find" => () => _ = tables.TryFind(default, new MemoryRecordCollectionRequest(8), out _, out _, + token), + "Tables.Create" => () => _ = tables.TryCreate(default, out _, out _, token), + "Tables.Update" => () => _ = tables.TryUpdate(record, default, out _, out _, token), + "Tables.GetHierarchy" => () => _ = tables.TryGetHierarchy(record, default, out _, out _, token), + "Tables.LoadTrustedTable" => () => _ = tables.TryLoadTrustedTable(default, out _, token), + "Lua.RegisterModule" => () => _ = new LuaClient(dispatcher, lifetime) + .TryRegisterModule(null!, out _, out _, token), + "UnsafeLua.Execute" => () => _ = new UnsafeLuaClient(dispatcher, + new CoreClientPolicy([], enableUnsafeLuaExecution: true), lifetime) + .TryExecute(default, out _, token), + "AutoAssembler.ApplyPatch" => () => _ = new AutoAssemblerClient(dispatcher, AutoAssemblerPolicy(), + lifetime, Binder(dispatcher), ports) + .TryApplyPatch(default, out _, out _, token), + "Assembly.Assemble" => () => _ = new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports) + .TryAssemble(default, out _, out _, token), + _ => throw new ArgumentOutOfRangeException(nameof(entryPoint), entryPoint, null) + }; + int dispatches = invoker.Calls; + switch (state) + { + case "Ended": + context.IsCurrent = false; + break; + case "Stopping": + context.Stop(); + break; + } + + _ = Assert.ThrowsAny(invalid); + + Assert.Equal(dispatches, invoker.Calls); + Assert.Equal(0, ports.Calls); + Assert.Empty(scans.Session.Calls); + Assert.Equal(0, allocations.Allocations); + } + + /// + /// A refusal of a well-formed request that the Client decides without calling Cheat Engine (a budget, an + /// unsupported type, a policy, a limit of Cheat Engine's own indexes, a range without room for a match, a + /// self-parent) never hides an ended or stopping activation: every family checks the activation first, under + /// the name of its own operation. + /// + [Theory] + [InlineData("Patterns.Scan", false)] + [InlineData("Patterns.Scan", true)] + [InlineData("Memory.ReadPrimitive", false)] + [InlineData("Memory.ReadBytes", true)] + [InlineData("Tables.SetParent", false)] + [InlineData("Tables.LoadTrustedTable", true)] + [InlineData("ValueScans.Read", false)] + [InlineData("ValueScans.Read", true)] + [InlineData("UnsafeLua.Execute", false)] + [InlineData("AutoAssembler.ApplyPatch", true)] + public void AnEndedOrStoppingActivationThrowsBeforeARequestIsRefused(string entryPoint, bool stopping) + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + ThrowingPorts ports = new(new InvalidOperationException("never reached")); + FakeValueScanPort scans = new(); + CancellationToken token = TestContext.Current.CancellationToken; + IValueScanSession session = new ValueScanner(dispatcher, Binder(dispatcher), scans).CreateSession(token); + MemoryClient memory = new(dispatcher, lifetime, ports); + CoreClientPolicy withoutOptIns = new([], enableUnsafeLuaExecution: false); + TableClient tables = new(dispatcher, withoutOptIns, ports, lifetime, ports); + MemoryRecordId record = new(7); + if (stopping) + { + context.Stop(); + } + else + { + context.IsCurrent = false; + } + + Action refused = entryPoint switch + { + "Patterns.Scan" => () => _ = new PatternScanner(dispatcher, ports).TryScan(NoRoomForAMatch(), out _, + out _, token), + "Memory.ReadPrimitive" => () => _ = memory.TryReadPrimitive(Target, out decimal _, out _, token), + "Memory.ReadBytes" => () => _ = memory.TryReadBytes( + new MemoryBytesReadRequest(Target, MemoryResourceLimits.DefaultMaximumReadBytes + 1), out _, out _, + token), + "Tables.SetParent" => () => _ = tables.TrySetParent(record, record, out _, out _, token), + "Tables.LoadTrustedTable" => () => _ = tables.TryLoadTrustedTable(new TableLoadRequest( + new TrustedTableFile(Path.Combine(Path.GetTempPath(), "untrusted.ct"))), out _, token), + "ValueScans.Read" => () => _ = session.TryRead(new ValueScanReadRequest((long) int.MaxValue + 1, 1), + out _, out _, token), + "UnsafeLua.Execute" => () => _ = new UnsafeLuaClient(dispatcher, withoutOptIns, lifetime) + .TryExecute(new LuaScript("return 1"), out _, token), + "AutoAssembler.ApplyPatch" => () => _ = new AutoAssemblerClient(dispatcher, withoutOptIns, lifetime, + Binder(dispatcher), ports) + .TryApplyPatch(new AutoAssemblerScript("[ENABLE]"), out _, out _, token), + _ => throw new ArgumentOutOfRangeException(nameof(entryPoint), entryPoint, null) + }; + + CheatEngineClientException thrown = stopping + ? Assert.Throws(refused) + : Assert.Throws(refused); + Assert.Equal(entryPoint, thrown.Failure.Operation); + Assert.Equal(CheatEngineHostEffect.NotStarted, thrown.Failure.HostEffect); + Assert.Equal(0, ports.Calls); + Assert.Empty(scans.Session.Calls); + } + + /// + /// Every public operation that needs the activation reports an ended activation with + /// and a stopping one with + /// under its own Service.Member name, from its Try, its + /// throwing and its Detailed forms, before any Cheat Engine call: never under the dispatcher's name. + /// IProcessClient.TryGetLocalProcesses needs no activation. + /// + [Theory] + [MemberData(nameof(ActivationRefusalCases))] + public void EveryOperationNamesTheActivationRefusalAfterItself(string operation, bool stopping) + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + ThrowingPorts ports = new(new InvalidOperationException("never reached")); + FakeValueScanPort scans = new(); + FakeAllocationPort allocations = new(); + CancellationToken token = TestContext.Current.CancellationToken; + IValueScanSession session = new ValueScanner(dispatcher, Binder(dispatcher), scans).CreateSession(token); + Action[] forms = OperationForms(operation, new OperationClients(dispatcher, lifetime, ports, scans, allocations, + session), token); + if (stopping) + { + context.Stop(); + } + else + { + context.IsCurrent = false; + } + + Assert.NotEmpty(forms); + Assert.All(forms, form => + { + CheatEngineClientException thrown = stopping + ? Assert.Throws(form) + : Assert.Throws(form); + Assert.Equal(operation, thrown.Failure.Operation); + Assert.Equal(CheatEngineHostEffect.NotStarted, thrown.Failure.HostEffect); + }); + Assert.Equal(0, ports.Calls); + Assert.Equal(1, scans.Creations); + Assert.Empty(scans.Session.Calls); + Assert.Equal(0, allocations.Allocations); + } + + /// + /// Creating a lease-owned resource is admitted only while the activation is active, never from the deactivation + /// cleanup scope that can still release an existing lease: every lease-creating operation throws there before it + /// dispatches anything. + /// + [Theory] + [Trait("Qualification", "Q43")] + [InlineData("Inspection.RegisterSymbol")] + [InlineData("Lua.RegisterModule")] + [InlineData("ValueScans.CreateSession")] + [InlineData("Allocations.Allocate")] + [InlineData("AutoAssembler.ApplyPatch")] + public void NoLeaseIsCreatedFromTheDeactivationCleanupScope(string entryPoint) + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + CountingMainThreadInvoker invoker = new(); + SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); + ThrowingPorts ports = new(new InvalidOperationException("never reached")); + FakeValueScanPort scans = new(); + FakeAllocationPort allocations = new(); + CancellationToken token = TestContext.Current.CancellationToken; + Action create = entryPoint switch + { + "Inspection.RegisterSymbol" => () => new InspectionClient(dispatcher, lifetime, ports) + .TryRegisterSymbol(new SymbolRegistration("contractSymbol", Target), out _, out _, token), + "Lua.RegisterModule" => () => new LuaClient(dispatcher, lifetime) + .TryRegisterModule(new PortBackedModule(ports), out _, out _, token), + "ValueScans.CreateSession" => () => new ValueScanner(dispatcher, Binder(dispatcher), scans) + .TryCreateSession(out _, out _, token), + "Allocations.Allocate" => () => new AllocationClient(dispatcher, Binder(dispatcher), allocations) + .TryAllocate(new AllocationRequest(4096), out _, out _, token), + "AutoAssembler.ApplyPatch" => () => new AutoAssemblerClient(dispatcher, AutoAssemblerPolicy(), lifetime, + Binder(dispatcher), ports) + .TryApplyPatch(new AutoAssemblerScript("[ENABLE]"), out _, out _, token), + _ => throw new ArgumentOutOfRangeException(nameof(entryPoint), entryPoint, null) + }; + + context.Stop(); + using (lifetime.EnterCleanupScope()) + { + CheatEngineInvalidStateException refused = Assert.Throws(create); + Assert.Equal(entryPoint, refused.Failure.Operation); + } + + Assert.Equal(0, invoker.Calls); + Assert.Equal(0, ports.Calls); + Assert.Equal(0, scans.Creations); + Assert.Equal(0, allocations.Allocations); + } + + [Theory] + [MemberData(nameof(SdkFaultCases))] + public void SdkExceptionsNeverCrossATryMethod(string faultType, string entryPoint) + { + Exception fault = CreateSdkFault(faultType); + CheatEngineFailureKind expectedKind = SdkFaultKinds[faultType]; + ThrowingPorts ports = new(fault) + { + SucceedingWrites = 1 + }; + CoreLifetime lifetime = InertCoreLifetime.Create(); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + CoreClientPolicy policy = new([], false); + CancellationToken token = TestContext.Current.CancellationToken; + + (bool succeeded, CheatEngineFailure failure, Action throwingForm) = entryPoint switch + { + "Patterns.Scan" => Run(new PatternScanner(dispatcher, ports), Request(), + static (scanner, request, t) => (scanner.TryScan(request, out _, out CheatEngineFailure f, t), f), + static (scanner, request, t) => scanner.Scan(request, t), token), + "Patterns.Scan.InModule" => Run(new PatternScanner(dispatcher, ports), Request(new ModuleName("game.exe")), + static (scanner, request, t) => (scanner.TryScan(request, out _, out CheatEngineFailure f, t), f), + static (scanner, request, t) => scanner.Scan(request, t), token), + "Inspection.GetSymbol" => Run(new InspectionClient(dispatcher, lifetime, ports), + new SymbolExpression("game.exe+10"), + static (client, expression, t) => + (client.TryGetSymbol(expression, out _, out CheatEngineFailure f, t), f), + static (client, expression, t) => client.GetSymbol(expression, t), token), + "Inspection.RegisterSymbol" => Run(new InspectionClient(dispatcher, lifetime, ports), + new SymbolRegistration("contractSymbol", Target), + static (client, registration, t) => + (client.TryRegisterSymbol(registration, out _, out CheatEngineFailure f, t), f), + static (client, registration, t) => client.RegisterSymbol(registration, t), token), + "Inspection.ResolveName" => Run(new InspectionClient(dispatcher, lifetime, ports), Target, + static (client, address, t) => (client.TryResolveName(address, out _, out CheatEngineFailure f, t), f), + static (client, address, t) => client.ResolveName(address, t), token), + "Tables.GetRecord" => Run(new TableClient(dispatcher, policy, ports, lifetime, ports), + new MemoryRecordId(7), + static (client, id, t) => (client.TryGetRecord(id, out _, out CheatEngineFailure f, t), f), + static (client, id, t) => client.GetRecord(id, t), token), + "Tables.Delete" => Run(new TableClient(dispatcher, policy, ports, lifetime, ports), + new MemoryRecordId(7), + static (client, id, t) => (client.TryDelete(id, out CheatEngineFailure f, t), f), + static (client, id, t) => client.Delete(id, t), token), + "Memory.ReadPrimitive" => Run(new MemoryClient(dispatcher, lifetime, ports), Target, + static (client, address, t) => (client.TryReadPrimitive(address, out int _, out CheatEngineFailure f, t), f), + static (client, address, t) => client.ReadPrimitive(address, t), token), + "Memory.ReadBytes" => Run(new MemoryClient(dispatcher, lifetime, ports), new MemoryBytesReadRequest(Target, 4), + static (client, request, t) => (client.TryReadBytes(request, out _, out CheatEngineFailure f, t), f), + static (client, request, t) => client.ReadBytes(request, t), token), + "Memory.WritePrimitiveBatch" => Run(new MemoryClient(dispatcher, lifetime, ports), + new MemoryPrimitiveBatchWriteRequest([ + new MemoryAddressValue(Target, 1), new MemoryAddressValue(Target + 4, 2) + ]), + static (client, request, t) => (client.TryWritePrimitiveBatch(request, out CheatEngineFailure f, t), f), + static (client, request, t) => client.WritePrimitiveBatch(request, t), token), + "Memory.ResolvePointerChain" => Run(new MemoryClient(dispatcher, lifetime, ports), + new PointerChainRequest(Target, [0x10L, 0x8L]), + static (client, request, t) => + (client.TryResolvePointerChain(request, out _, out CheatEngineFailure f, t), f), + static (client, request, t) => client.ResolvePointerChain(request, t), token), + "Processes.GetCurrentProcess" => Run(new ProcessClient(dispatcher, ports, ports, ports, lifetime), 0, + static (client, _, t) => + (client.TryGetCurrentProcess(out ProcessSnapshot _, out CheatEngineFailure f, t), f), + static (client, _, t) => client.GetCurrentProcess(t), token), + "Processes.Attach" => Run(new ProcessClient(dispatcher, ports, ports, ports, lifetime), new TargetProcessId(43), + static (client, processId, t) => (client.TryAttach(processId, out _, out CheatEngineFailure f, t), f), + static (client, processId, t) => client.Attach(processId, t), token), + "Runtime.GetSnapshot" => Run(new RuntimeClient(dispatcher, ports, static () => 1), 0, + static (client, _, t) => (client.TryGetSnapshot(out CheatEngineRuntimeSnapshot _, out CheatEngineFailure f, t), f), + static (client, _, t) => client.GetSnapshot(t), token), + // A generated module's Register surfaces the SDK faults of its registration; the Client classifies them. + "Lua.RegisterModule" => Run(new LuaClient(dispatcher, lifetime), new PortBackedModule(ports), + static (client, module, t) => (client.TryRegisterModule(module, out _, out CheatEngineFailure f, t), f), + static (client, module, t) => client.RegisterModule(module, t), token), + "ValueScans.CreateSession" => Run( + new ValueScanner(dispatcher, Binder(dispatcher), new FakeValueScanPort { Fault = fault }), 0, + static (scanner, _, t) => (scanner.TryCreateSession(out IValueScanSession? _, out CheatEngineFailure f, t), f), + static (scanner, _, t) => scanner.CreateSession(t), token), + "ValueScans.GetResultCount" => Run(CreateScanSession(dispatcher, fault, token), 0, + static (session, _, t) => (session.TryGetResultCount(out ulong _, out CheatEngineFailure f, t), f), + static (session, _, t) => session.GetResultCount(t), token), + "Allocations.Allocate" => Run( + new AllocationClient(dispatcher, Binder(dispatcher), new FakeAllocationPort { Fault = fault }), + new AllocationRequest(4096), + static (client, request, t) => (client.TryAllocate(request, out ITargetMemoryLease? _, + out CheatEngineFailure f, t), f), + static (client, request, t) => client.Allocate(request, t), token), + "AutoAssembler.ApplyPatch" => Run( + new AutoAssemblerClient(dispatcher, AutoAssemblerPolicy(), lifetime, Binder(dispatcher), ports), + new AutoAssemblerScript("[ENABLE]"), + static (client, script, t) => (client.TryApplyPatch(script, out _, out CheatEngineFailure f, t), f), + static (client, script, t) => client.ApplyPatch(script, t), token), + "AutoAssembler.Check" => Run( + new AutoAssemblerClient(dispatcher, AutoAssemblerPolicy(), lifetime, Binder(dispatcher), ports), + new AutoAssemblerScript("[ENABLE]"), + static (client, script, t) => (client.TryCheck(script, out _, out CheatEngineFailure f, t), f), + static (client, script, t) => client.Check(script, t), token), + "Assembly.Assemble" => Run(new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports), + new AssemblyInstructionRequest(Target, "nop"), + static (client, request, t) => (client.TryAssemble(request, out _, out CheatEngineFailure f, t), f), + static (client, request, t) => client.Assemble(request, t), token), + "Assembly.Disassemble" => Run(new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports), + Target, + static (client, address, t) => (client.TryDisassemble(address, out _, out CheatEngineFailure f, t), f), + static (client, address, t) => client.Disassemble(address, t), token), + "Assembly.GetInstructionLength" => Run( + new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports), Target, + static (client, address, t) => + (client.TryGetInstructionLength(address, out _, out CheatEngineFailure f, t), f), + static (client, address, t) => client.GetInstructionLength(address, t), token), + "Assembly.GetPreviousInstructionAddress" => Run( + new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports), Target, + static (client, address, t) => + (client.TryGetPreviousInstructionAddress(address, out _, out CheatEngineFailure f, t), f), + static (client, address, t) => client.GetPreviousInstructionAddress(address, t), token), + _ => throw new ArgumentOutOfRangeException(nameof(entryPoint), entryPoint, null) + }; + + Assert.False(succeeded); + Assert.Equal(expectedKind, failure.Kind); + // An entry point is named after the operation it reports, with an optional qualifier ("Patterns.Scan.InModule"). + Assert.Equal(string.Join('.', entryPoint.Split('.').Take(2)), failure.Operation); + Assert.Same(fault, failure.Exception); + Assert.NotEqual(CheatEngineFailureKind.ActivationExpired, failure.Kind); + CheatEngineClientException thrown = Assert.ThrowsAny(throwingForm); + Assert.Same(fault, thrown.Failure.Exception); + Assert.Equal(expectedKind, thrown.Failure.Kind); + } + + /// + /// The scoped AOB route reaches two more SDK calls after module resolution: the target observation that selects the + /// route, and the bounded scan itself. Neither lets an SDK fault cross a Try method, and each keeps the classified + /// kind, the scan operation and its own host effect. + /// + [Theory] + [MemberData(nameof(ScopedAobFaultCases))] + public void SdkExceptionsFromTheScopedAobRouteNeverCrossATryMethod(string faultType, string stage) + { + Exception fault = CreateSdkFault(faultType); + CheatEngineFailureKind expectedKind = SdkFaultKinds[faultType]; + ThrowingPorts ports = new(fault) + { + ModuleSnapshot = [new ModuleInfo("game.exe", new Address(0x4000), new MemorySize(0x100), true, "game.exe")], + Selection = stage == "TryScanWithinBounds" ? AobHosts.Local() : null + }; + PatternScanner scanner = new( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), ports); + AobScanRequest request = Request(new ModuleName("game.exe")); + CancellationToken token = TestContext.Current.CancellationToken; + + bool succeeded = scanner.TryScan(request, out _, out CheatEngineFailure failure, token); + + Assert.False(succeeded); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Same(fault, failure.Exception); + Assert.Equal(stage == "TryScanWithinBounds" ? CheatEngineHostEffect.Unknown : CheatEngineHostEffect.NotStarted, + failure.HostEffect); + CheatEngineClientException thrown = Assert.ThrowsAny(() => + scanner.Scan(request, token)); + Assert.Same(fault, thrown.Failure.Exception); + Assert.Equal(expectedKind, thrown.Failure.Kind); + } + + [Fact] + public void AnInterruptedBatchWriteKeepsItsCompletedPrefixAndAnUnknownEffect() + { + InvalidOperationException fault = new("detached during the second write"); + ThrowingPorts ports = new(fault) + { + SucceedingWrites = 1 + }; + CoreLifetime lifetime = InertCoreLifetime.Create(); + MemoryClient client = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, + ports); + + MemoryPrimitiveBatchWriteOutcome outcome = client.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([ + new MemoryAddressValue(Target, 1), new MemoryAddressValue(Target + 4, 2), + new MemoryAddressValue(Target + 8, 3) + ]), TestContext.Current.CancellationToken); + + Assert.False(outcome.IsSuccess); + Assert.Equal(1, outcome.CompletedCount); + Assert.Equal(1, outcome.FailedIndex); + Assert.Equal(MemoryBatchWriteEffectState.Unknown, outcome.EffectState); + Assert.Same(fault, outcome.Failure!.Value.Exception); + Assert.Equal(CheatEngineHostEffect.Unknown, outcome.Failure.Value.HostEffect); + } + + [Fact] + public void StaticSdkBindingFailuresNeverCrossATryMethod() + { + CoreLifetime lifetime = InertCoreLifetime.Create(); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + string root = Directory.CreateTempSubdirectory("ce-client-try-contract-").FullName; + try + { + string tablePath = Path.Combine(root, "trusted.ct"); + File.WriteAllText(tablePath, ""); + TableClient tables = new(dispatcher, new CoreClientPolicy([root], false), lifetime: lifetime); + InspectionClient inspection = new(dispatcher, lifetime); + MemoryClient memory = new(dispatcher, lifetime); + PatternScanner patterns = new(dispatcher); + UnsafeLuaClient unsafeLua = new(dispatcher, new CoreClientPolicy([], true), lifetime); + ValueScanner scans = new(dispatcher, Binder(dispatcher)); + AllocationClient allocations = new(dispatcher, Binder(dispatcher)); + AutoAssemblerClient autoAssembler = new(dispatcher, AutoAssemblerPolicy(), lifetime, Binder(dispatcher)); + AssemblyClient instructions = new(dispatcher, lifetime, new MemoryResourceLimits()); + CancellationToken token = TestContext.Current.CancellationToken; + + // No Lua runtime is attached in unit tests: every SDK static below throws InvalidOperationException, and the + // SDK's own admission reports Detached. CheatTableFiles.TryLoad behind the trusted table load. + Assert.False(tables.TryLoadTrustedTable(new TableLoadRequest(new TrustedTableFile(tablePath)), + out CheatEngineFailure loadFailure, token)); + // AddressListMutations.Delete, which acquires its Lua operation itself, behind the record delete. + Assert.False(tables.TryDelete(new MemoryRecordId(7), out CheatEngineFailure deleteFailure, token)); + Assert.False(inspection.TryRegisterSymbol(new SymbolRegistration("contractSymbol", Target), + out ISymbolRegistrationLease? lease, out CheatEngineFailure registerFailure, token)); + // SymbolRegistry.TryGetName behind the symbol-name lookup. + Assert.False(inspection.TryResolveName(Target, out string? name, out CheatEngineFailure nameFailure, token)); + Assert.False(memory.TryReadBytes(new MemoryBytesReadRequest(Target, 8), out ImmutableArray bytes, + out CheatEngineFailure readFailure, token)); + // The counted TargetMemory.TryReadBytes overload behind the prefix-reporting read. + MemoryBytesReadOutcome detailed = memory.ReadBytesDetailed(new MemoryBytesReadRequest(Target, 8), token); + // The global route: AobScanner.TryScanOutcome with its target context. + Assert.False(patterns.TryScan(Request(), out _, out CheatEngineFailure scanFailure, token)); + Assert.False(unsafeLua.TryExecute(new LuaScript("return 1"), out CheatEngineFailure luaFailure, token)); + // MemoryScanSessions.TryCreateWithOutcome, behind the value-scan session factory. + Assert.False(scans.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure createFailure, + token)); + // TargetMemoryAllocator.TryAllocate, behind the allocation client. + Assert.False(allocations.TryAllocate(new AllocationRequest(4096), out ITargetMemoryLease? allocation, + out CheatEngineFailure allocateFailure, token)); + Assert.False(autoAssembler.TryApplyPatch(new AutoAssemblerScript("[ENABLE]"), out IAutoAssemblerPatchLease? patch, + out CheatEngineFailure applyFailure, token)); + Assert.False(autoAssembler.TryCheck(new AutoAssemblerScript("[ENABLE]"), out _, + out CheatEngineFailure checkFailure, token)); + Assert.False(instructions.TryAssemble(new AssemblyInstructionRequest(Target, "nop"), + out ImmutableArray assembled, out CheatEngineFailure assembleFailure, token)); + Assert.False(instructions.TryDisassemble(Target, out AssemblyInstructionSnapshot disassembled, + out CheatEngineFailure disassembleFailure, token)); + + Assert.Null(lease); + Assert.Null(name); + Assert.Null(session); + Assert.Null(allocation); + Assert.True(bytes.IsEmpty); + Assert.Equal(0, detailed.ConfirmedLength); + CheatEngineFailure[] failures = + [ + loadFailure, deleteFailure, registerFailure, nameFailure, readFailure, detailed.Failure!.Value, + scanFailure, createFailure, allocateFailure + ]; + Assert.All( + failures, + static failure => + { + Assert.IsType(failure.Exception); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + }); + // Unsafe Lua asks for its admission through LuaAdmission: a detached runtime is an expired activation that + // never reached Cheat Engine, not a rejection. + Assert.Null(luaFailure.Exception); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, luaFailure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, luaFailure.HostEffect); + Assert.Equal("UnsafeLua.Execute", luaFailure.Operation); + // The Auto Assembler port asks for the same admission before AutoAssemblerPatcher runs. + Assert.Null(patch); + CheatEngineFailure[] autoAssemblerFailures = [applyFailure, checkFailure]; + Assert.All( + autoAssemblerFailures, + static failure => + { + Assert.Null(failure.Exception); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + }); + Assert.Equal(["AutoAssembler.ApplyPatch", "AutoAssembler.Check"], + autoAssemblerFailures.Select(static failure => failure.Operation)); + // The instruction port asks for one admission before the profile observation and every instruction call. + Assert.True(assembled.IsDefault); + Assert.Equal(default, disassembled); + CheatEngineFailure[] instructionFailures = [assembleFailure, disassembleFailure]; + Assert.All( + instructionFailures, + static failure => + { + Assert.Null(failure.Exception); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + }); + Assert.Equal(["Assembly.Assemble", "Assembly.Disassemble"], + instructionFailures.Select(static failure => failure.Operation)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void AnAdmissionRefusedInsideAnSdkAddressListCommandIsARejectionWhileTheActivationIsCurrent() + { + // LuaAdmission classifies only the admissions Core asks for itself (unsafe Lua, above). AddressListMutations + // acquires its own admission and raises a plain InvalidOperationException when it is refused. No Lua runtime is + // attached in unit tests, so the SDK refuses it as Detached while this Client activation is still current. + CoreLifetime lifetime = InertCoreLifetime.Create(); + TableClient tables = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), + new CoreClientPolicy([], false), lifetime: lifetime); + + bool succeeded = tables.TrySetParent(new MemoryRecordId(7), new MemoryRecordId(8), out _, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + CheatEngineClientException thrown = Assert.ThrowsAny(() => + tables.SetParent(new MemoryRecordId(7), new MemoryRecordId(8), TestContext.Current.CancellationToken)); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, failure.HostEffect); + Assert.Equal("Tables.SetParent", failure.Operation); + Assert.IsType(failure.Exception); + Assert.Equal(CheatEngineFailureKind.OperationRejected, thrown.Failure.Kind); + } + + [Fact] + public void InvalidOperationExceptionAfterAnExternalResetIsReportedAsRuntimeChanged() + { + InvalidOperationException fault = new("The host replaced its Lua state outside this SDK's controlled reset path."); + + CheatEngineFailure afterReset = SdkBoundary.Classify("Memory.ReadBytes", fault, CheatEngineHostEffect.Unknown, + externalStateResetDetected: true); + CheatEngineFailure withoutReset = SdkBoundary.Classify("Memory.ReadBytes", fault, CheatEngineHostEffect.Unknown, + externalStateResetDetected: false); + + Assert.Equal(CheatEngineFailureKind.RuntimeChanged, afterReset.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, afterReset.HostEffect); + Assert.Equal("Memory.ReadBytes", afterReset.Operation); + Assert.Same(fault, afterReset.Exception); + Assert.Equal(CheatEngineFailureKind.OperationRejected, withoutReset.Kind); + } + + [Fact] + public void TheExternalResetFactIsReadWithoutALuaAdmission() + { + // The fact Hosting reads when it deactivates the plugin (event 8) is the SDK's lock-free sticky flag: it needs + // no Lua admission and no Cheat Engine. No Lua state was ever replaced here. + Assert.False(SdkBoundary.ExternalStateResetDetected); + } + + [Fact] + public void SdkFaultsWithTheirOwnCategoryKeepItAfterAnExternalReset() + { + Exception[] faults = + [ + new ObjectDisposedException("contract.disposed"), + (Exception) RuntimeHelpers.GetUninitializedObject(typeof(MemoryScanStateException)), + new EngineGlobalUnavailableException("contract.global", "global fault"), + new ArgumentException("contract argument") + ]; + + foreach (Exception fault in faults) + { + Assert.Equal(CoreFailureFactory.GetKind(fault), + SdkBoundary.Classify("Memory.ReadBytes", fault, CheatEngineHostEffect.Unknown, true).Kind); + } + } + + [Fact] + public void SdkFaultObservedAfterTheActivationEndedIsReportedAsActivationExpired() + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + ThrowingPorts ports = new(new InvalidOperationException("the runtime detached")) + { + BeforeFault = () => context.IsCurrent = false + }; + PatternScanner scanner = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), ports); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + scanner.TryScan(Request(), out _, out _, TestContext.Current.CancellationToken)); + + Assert.IsType(exception.InnerException); + } + + [Fact] + public void ConsumerCallbackExceptionsAreRethrownAsTheSameInstance() + { + CoreLifetime lifetime = InertCoreLifetime.Create(); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + ConsumerException codecFault = new("codec"); + Exception codecClientFault = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Application.Codec", + "application-owned client exception").ToException(TestContext.Current.CancellationToken); + ConsumerException operationFault = new("operation"); + ConsumerException callbackFault = new("callback"); + MemoryClient memory = new(dispatcher, lifetime, new ThrowingPorts(new InvalidOperationException("unused"))); + LuaClient lua = new(dispatcher, lifetime); + + Assert.Same(codecFault, Assert.Throws(() => memory.TryRead( + new MemoryReadRequest(Target, new ThrowingCodec(codecFault)), out _, out _, + TestContext.Current.CancellationToken))); + Assert.Same(codecClientFault, Assert.Throws(() => memory.TryWrite( + new MemoryWriteRequest(Target, 3, new ThrowingCodec(codecClientFault)), out _, + TestContext.Current.CancellationToken))); + Assert.Same(operationFault, Assert.Throws(() => lua.TryExecute( + new ThrowingOperation(operationFault), out _, out _, TestContext.Current.CancellationToken))); + Assert.Same(callbackFault, Assert.Throws(() => dispatcher.TryInvoke( + () => throw callbackFault, out _, TestContext.Current.CancellationToken))); + } + + [Theory] + [InlineData("PatternsPreDispatchCancellation")] + [InlineData("PatternsNoRoomForAMatch")] + [InlineData("MemoryBudget")] + [InlineData("MemoryBatchPreDispatchCancellation")] + [InlineData("MemoryBytesDetailedBudget")] + [InlineData("TablesPolicy")] + [InlineData("UnsafeLuaPolicy")] + [InlineData("LuaPreDispatchCancellation")] + [InlineData("ProcessesAttachExactNameCancellation")] + [InlineData("LuaModuleRegistrationPreDispatchCancellation")] + [InlineData("ValueScansPreDispatchCancellation")] + [InlineData("ValueScansResultIndexLimit")] + [InlineData("AllocationsPreDispatchCancellation")] + [InlineData("AutoAssemblerPolicy")] + [InlineData("AutoAssemblerPreDispatchCancellation")] + [InlineData("InstructionsPreDispatchCancellation")] + public void RefusalBeforeStartReportsNotStarted(string refusal) + { + CoreLifetime lifetime = InertCoreLifetime.Create(); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + ThrowingPorts ports = new(new InvalidOperationException("must not be reached")); + CancellationToken cancelled = new(true); + CancellationToken token = TestContext.Current.CancellationToken; + + CheatEngineFailure failure = refusal switch + { + "PatternsPreDispatchCancellation" => TryFailure(() => + (new PatternScanner(dispatcher, ports).TryScan(Request(), out _, out CheatEngineFailure f, cancelled), f)), + "PatternsNoRoomForAMatch" => TryFailure(() => (new PatternScanner(dispatcher, ports).TryScan( + NoRoomForAMatch(), out _, out CheatEngineFailure f, token), f)), + "MemoryBudget" => TryFailure(() => + (new MemoryClient(dispatcher, lifetime, ports, new MemoryResourceLimits(1, 1, 1, 64, 2)) + .TryReadBytes(new MemoryBytesReadRequest(Target, 2), out _, out CheatEngineFailure f, token), f)), + "MemoryBatchPreDispatchCancellation" => new MemoryClient(dispatcher, lifetime, ports) + .WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([new MemoryAddressValue(Target, 1)]), cancelled) + .Failure!.Value, + "MemoryBytesDetailedBudget" => new MemoryClient(dispatcher, lifetime, ports, + new MemoryResourceLimits(1, 1, 1, 64, 2)) + .ReadBytesDetailed(new MemoryBytesReadRequest(Target, 2), token).Failure!.Value, + "TablesPolicy" => TryFailure(() => (new TableClient(dispatcher, new CoreClientPolicy([], false), ports, + lifetime, ports).TryLoadTrustedTable(new TableLoadRequest(new TrustedTableFile( + Path.Combine(Path.GetTempPath(), "untrusted.ct"))), out CheatEngineFailure f, token), f)), + "UnsafeLuaPolicy" => TryFailure(() => + (new UnsafeLuaClient(dispatcher, new CoreClientPolicy([], false), lifetime) + .TryExecute(new LuaScript("return 1"), out CheatEngineFailure f, token), f)), + "LuaPreDispatchCancellation" => TryFailure(() => + (new LuaClient(dispatcher, lifetime).TryExecute(new ConstantOperation(), out _, + out CheatEngineFailure f, cancelled), f)), + "ProcessesAttachExactNameCancellation" => TryFailure(() => + (new ProcessClient(dispatcher, ports, ports, ports, lifetime).TryAttachExactName("fixture.exe", out _, + out CheatEngineFailure f, cancelled), f)), + "LuaModuleRegistrationPreDispatchCancellation" => TryFailure(() => + (new LuaClient(dispatcher, lifetime).TryRegisterModule(new PortBackedModule(ports), out _, + out CheatEngineFailure f, cancelled), f)), + "ValueScansPreDispatchCancellation" => TryFailure(() => + (new ValueScanner(dispatcher, Binder(dispatcher), new FakeValueScanPort()).TryCreateSession(out _, + out CheatEngineFailure f, cancelled), f)), + "ValueScansResultIndexLimit" => TryFailure(() => + (new ValueScanner(dispatcher, Binder(dispatcher), new FakeValueScanPort()).CreateSession(token) + .TryRead(new ValueScanReadRequest((long) int.MaxValue + 1, 1), out _, out CheatEngineFailure f, + token), f)), + "AllocationsPreDispatchCancellation" => TryFailure(() => + (new AllocationClient(dispatcher, Binder(dispatcher), new FakeAllocationPort()) + .TryAllocate(new AllocationRequest(4096), out _, out CheatEngineFailure f, cancelled), f)), + "AutoAssemblerPolicy" => TryFailure(() => + (new AutoAssemblerClient(dispatcher, new CoreClientPolicy([], false), lifetime, Binder(dispatcher), + ports) + .TryApplyPatch(new AutoAssemblerScript("[ENABLE]"), out _, out CheatEngineFailure f, token), f)), + "AutoAssemblerPreDispatchCancellation" => TryFailure(() => + (new AutoAssemblerClient(dispatcher, AutoAssemblerPolicy(), lifetime, Binder(dispatcher), ports) + .TryCheck(new AutoAssemblerScript("[ENABLE]"), out _, out CheatEngineFailure f, cancelled), f)), + "InstructionsPreDispatchCancellation" => TryFailure(() => + (new AssemblyClient(dispatcher, lifetime, new MemoryResourceLimits(), ports) + .TryAssemble(new AssemblyInstructionRequest(Target, "nop"), out _, out CheatEngineFailure f, + cancelled), f)), + _ => throw new ArgumentOutOfRangeException(nameof(refusal), refusal, null) + }; + + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal(0, ports.Calls); + } + + /// + /// The throwing form of each family raises the cancellation exception that carries its Try form's failure and the + /// caller's token, never a Client operation exception (AUD-12). + /// + [Theory] + [InlineData("Patterns")] + [InlineData("Memory")] + [InlineData("Inspection")] + [InlineData("Tables")] + [InlineData("Lua")] + [InlineData("Dispatcher")] + [InlineData("Processes")] + [InlineData("Runtime")] + [InlineData("LuaModuleRegistration")] + [InlineData("ValueScans")] + [InlineData("Allocations")] + [InlineData("Instructions")] + public void ThrowingFormsRaiseTheCancellationExceptionOfTheirTryForm(string family) + { + CoreLifetime lifetime = InertCoreLifetime.Create(); + SdkMainThreadDispatcher dispatcher = new(lifetime, new InlineMainThreadInvoker()); + ThrowingPorts ports = new(new InvalidOperationException("must not be reached")); + PatternScanner patterns = new(dispatcher, ports); + MemoryClient memory = new(dispatcher, lifetime, ports); + InspectionClient inspection = new(dispatcher, lifetime, ports); + SymbolRegistration registration = new("contractSymbol", Target); + TableClient tables = new(dispatcher, CoreClientPolicy.SafeDefaults, ports, lifetime, ports); + LuaClient lua = new(dispatcher, lifetime); + ProcessClient processes = new(dispatcher, ports, ports, ports, lifetime); + RuntimeClient runtime = new(dispatcher, ports, static () => 1); + PortBackedModule module = new(ports); + ValueScanner scans = new(dispatcher, processes, new FakeValueScanPort()); + AllocationClient allocations = new(dispatcher, processes, new FakeAllocationPort()); + AssemblyClient instructions = new(dispatcher, lifetime, new MemoryResourceLimits(), ports); + CancellationToken cancelled = new(true); + + (CheatEngineFailure Expected, Action ThrowingForm) scenario = family switch + { + "Patterns" => (TryFailure(() => (patterns.TryScan(Request(), out _, out CheatEngineFailure f, cancelled), f)), + () => _ = patterns.Scan(Request(), cancelled)), + "Memory" => (TryFailure(() => (memory.TryReadPrimitive(Target, out int _, out CheatEngineFailure f, + cancelled), f)), () => _ = memory.ReadPrimitive(Target, cancelled)), + "Inspection" => (TryFailure(() => (inspection.TryRegisterSymbol(registration, out _, + out CheatEngineFailure f, cancelled), f)), () => _ = inspection.RegisterSymbol(registration, cancelled)), + "Tables" => (TryFailure(() => (tables.TryGetRecordCount(out _, out CheatEngineFailure f, cancelled), f)), + () => _ = tables.GetRecordCount(cancelled)), + "Lua" => (TryFailure(() => (lua.TryExecute(new ConstantOperation(), out _, + out CheatEngineFailure f, cancelled), f)), + () => _ = lua.Execute(new ConstantOperation(), cancelled)), + "Dispatcher" => (TryFailure(() => (dispatcher.TryInvoke(static () => + { + }, out CheatEngineFailure f, cancelled), f)), () => dispatcher.Invoke(static () => + { + }, cancelled)), + "Processes" => (TryFailure(() => (processes.TryRefresh(out _, out CheatEngineFailure f, cancelled), f)), + () => _ = processes.Refresh(cancelled)), + "Runtime" => (TryFailure(() => (runtime.TryGetSnapshot(out _, out CheatEngineFailure f, cancelled), f)), + () => _ = runtime.GetSnapshot(cancelled)), + "LuaModuleRegistration" => (TryFailure(() => + (lua.TryRegisterModule(module, out _, out CheatEngineFailure f, cancelled), f)), + () => _ = lua.RegisterModule(module, cancelled)), + "ValueScans" => (TryFailure(() => (scans.TryCreateSession(out _, out CheatEngineFailure f, cancelled), f)), + () => _ = scans.CreateSession(cancelled)), + "Allocations" => (TryFailure(() => (allocations.TryAllocate(new AllocationRequest(4096), out _, + out CheatEngineFailure f, cancelled), f)), + () => _ = allocations.Allocate(new AllocationRequest(4096), cancelled)), + "Instructions" => (TryFailure(() => (instructions.TryGetInstructionLength(Target, out _, + out CheatEngineFailure f, cancelled), f)), () => _ = instructions.GetInstructionLength(Target, cancelled)), + _ => throw new ArgumentOutOfRangeException(nameof(family), family, null) + }; + + CheatEngineOperationCanceledException exception = + Assert.Throws(scenario.ThrowingForm); + + Assert.Equal(CheatEngineFailureKind.Cancelled, scenario.Expected.Kind); + Assert.Equal(scenario.Expected, exception.Failure); + Assert.Equal(cancelled, exception.CancellationToken); + Assert.Equal(0, ports.Calls); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void EffectStartedThenCancelledReportsCompleted() + { + using CancellationTokenSource cancellation = new(); + ThrowingPorts ports = new(new InvalidOperationException("unused")) + { + ScanResult = ["400000"], + OnScan = cancellation.Cancel + }; + PatternScanner scanner = new( + new SdkMainThreadDispatcher(InertCoreLifetime.Create(), new InlineMainThreadInvoker()), ports); + + bool succeeded = scanner.TryScan(Request(), out AobScanResult result, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal(1, ports.ReleasedLists); + } + + [Fact] + [Trait("Qualification", "Q33")] + public void IncompleteBatchReportsPartialWithCount() + { + ThrowingPorts ports = new(new InvalidOperationException("unused")) + { + SucceedingWrites = 2, + RejectAfterSucceedingWrites = true + }; + CoreLifetime lifetime = InertCoreLifetime.Create(); + MemoryClient client = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, + ports); + + MemoryPrimitiveBatchWriteOutcome outcome = client.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest([ + new MemoryAddressValue(Target, 1), new MemoryAddressValue(Target + 4, 2), + new MemoryAddressValue(Target + 8, 3), new MemoryAddressValue(Target + 12, 4) + ]), TestContext.Current.CancellationToken); + + Assert.False(outcome.IsSuccess); + Assert.Equal(4, outcome.RequestedCount); + Assert.Equal(2, outcome.CompletedCount); + Assert.Equal(2, outcome.FailedIndex); + Assert.Equal(MemoryBatchWriteEffectState.Partial, outcome.EffectState); + Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, outcome.Failure!.Value.Kind); + Assert.Equal(CheatEngineHostEffect.Started, outcome.Failure.Value.HostEffect); + } + + [Fact] + public void ConsumerExceptionIsRethrownUnchanged() + { + ConsumerException applicationFault = new("application"); + SdkMainThreadDispatcher dispatcher = new(InertCoreLifetime.Create(), new InlineMainThreadInvoker()); + + ConsumerException thrown = Assert.Throws(() => + dispatcher.TryInvoke(() => throw applicationFault, out _, out _, + TestContext.Current.CancellationToken)); + + Assert.Same(applicationFault, thrown); + } + + private static (bool Succeeded, CheatEngineFailure Failure, Action ThrowingForm) Run( + TClient client, + TInput input, + Func tryForm, + Action throwingForm, + CancellationToken cancellationToken) + { + (bool succeeded, CheatEngineFailure failure) = tryForm(client, input, cancellationToken); + return (succeeded, failure, () => throwingForm(client, input, cancellationToken)); + } + + private static CoreClientPolicy AutoAssemblerPolicy() + { + return new CoreClientPolicy([], false, enableAutoAssemblerPatches: true); + } + + private static CheatEngineFailure TryFailure(Func<(bool Succeeded, CheatEngineFailure Failure)> attempt) + { + (bool succeeded, CheatEngineFailure failure) = attempt(); + Assert.False(succeeded); + return failure; + } + + /// Creates the selection binder of target-bound leases: a process client over the default selected target. + private static ProcessClient Binder(SdkMainThreadDispatcher dispatcher) + { + return FakeSelectedTarget.CreateProcessClient(dispatcher); + } + + /// Creates a value-scan session whose results are ready and whose result count throws . + private static IValueScanSession CreateScanSession(SdkMainThreadDispatcher dispatcher, Exception fault, + CancellationToken cancellationToken) + { + FakeValueScanPort port = new(); + IValueScanSession session = + new ValueScanner(dispatcher, Binder(dispatcher), port).CreateSession(cancellationToken); + session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), cancellationToken); + port.Session.CountFault = fault; + return session; + } + + private static AobScanRequest Request(ModuleName? module = null) + { + return new AobScanRequest(new AobPattern("90"), 2, module); + } + + /// + /// Creates a well-formed range request whose range leaves no room for a whole match below the top of the + /// address space: the scanner refuses it without calling Cheat Engine. + /// + private static AobScanRequest NoRoomForAMatch() + { + Address top = new(ulong.MaxValue); + return new AobScanRequest(new AobPattern("90 90"), 1, null, new AobScanRange(top, top)); + } + + /// + /// Returns the Try, throwing and Detailed forms of one public operation, with well-formed arguments. + /// + private static Action[] OperationForms(string operation, OperationClients clients, CancellationToken token) + { + IValueScanSession session = clients.Session; + MemoryClient memory = clients.Memory; + InspectionClient inspection = clients.Inspection; + TableClient tables = clients.Tables; + AssemblyClient assembly = clients.Assembly; + MemoryRecordId record = new(7); + MemoryRecordCollectionRequest records = new(8); + InspectionCollectionRequest items = new(8); + ModuleName module = new("game.exe"); + SymbolExpression expression = new("game.exe+10"); + SymbolRegistration registration = new("contractSymbol", Target); + MemoryPrimitiveBatchReadRequest batchRead = new([Target]); + MemoryPrimitiveBatchWriteRequest batchWrite = new([new MemoryAddressValue(Target, 1)]); + MemoryBytesReadRequest bytesRead = new(Target, 4); + MemoryBytesWriteRequest bytesWrite = new(Target, [1]); + MemoryStringReadRequest stringRead = new(Target, 16, MemoryStringEncoding.Utf8); + MemoryStringWriteRequest stringWrite = new(Target, "a", 16, MemoryStringEncoding.Utf8); + PointerChainRequest chain = new(Target, [0x10]); + ThrowingCodec codec = new(new InvalidOperationException("never reached")); + MemoryRecordDefinition definition = new("Ammo", "game.exe+10", "100", VariableType.Dword); + TrustedTableFile file = new(Path.Combine(Path.GetTempPath(), "contract.ct")); + AssemblyInstructionRequest instruction = new(Target, "nop"); + AutoAssemblerScript script = new("[ENABLE]"); + return operation switch + { + "Runtime.GetSnapshot" => + [ + () => clients.Runtime.TryGetSnapshot(out _, out _, token), () => clients.Runtime.GetSnapshot(token) + ], + "Runtime.GetClientCapability" => + [ + () => clients.Runtime.TryGetClientCapability(ClientCapabilityId.Inspection, out _, out _, token), + () => clients.Runtime.GetClientCapability(ClientCapabilityId.Inspection, token) + ], + "Dispatcher.Invoke" => + [ + () => clients.Dispatcher.TryInvoke(static () => + { + }, out _, token), + () => clients.Dispatcher.Invoke(static () => 1, token) + ], + "Processes.GetCurrentProcess" => + [ + () => clients.Processes.TryGetCurrentProcess(out _, out _, token), + () => clients.Processes.GetCurrentProcess(token) + ], + "Processes.Refresh" => + [() => clients.Processes.TryRefresh(out _, out _, token), () => clients.Processes.Refresh(token)], + "Processes.Attach" => + [ + () => clients.Processes.TryAttach(new TargetProcessId(42), out _, out _, token), + () => clients.Processes.Attach(new TargetProcessId(42), token) + ], + "Processes.AttachExactName" => + [ + () => clients.Processes.TryAttachExactName("fixture.exe", out _, out _, token), + () => clients.Processes.AttachExactName("fixture.exe", token) + ], + "Memory.ReadPrimitive" => + [ + () => memory.TryReadPrimitive(Target, out int _, out _, token), + () => memory.ReadPrimitive(Target, token) + ], + "Memory.WritePrimitive" => + [ + () => memory.TryWritePrimitive(Target, 1, out _, token), + () => memory.WritePrimitive(Target, 1, token) + ], + "Memory.ReadPrimitiveBatch" => + [ + () => memory.TryReadPrimitiveBatch(batchRead, out _, out _, token), + () => memory.ReadPrimitiveBatch(batchRead, token), + () => memory.ReadPrimitiveBatchDetailed(batchRead, token) + ], + "Memory.WritePrimitiveBatch" => + [ + () => memory.TryWritePrimitiveBatch(batchWrite, out _, token), + () => memory.WritePrimitiveBatch(batchWrite, token), + () => memory.WritePrimitiveBatchDetailed(batchWrite, token) + ], + "Memory.ReadBytes" => + [ + () => memory.TryReadBytes(bytesRead, out _, out _, token), () => memory.ReadBytes(bytesRead, token), + () => memory.ReadBytesDetailed(bytesRead, token) + ], + "Memory.WriteBytes" => + [() => memory.TryWriteBytes(bytesWrite, out _, token), () => memory.WriteBytes(bytesWrite, token)], + "Memory.ReadString" => + [ + () => memory.TryReadString(stringRead, out _, out _, token), + () => memory.ReadString(stringRead, token) + ], + "Memory.WriteString" => + [() => memory.TryWriteString(stringWrite, out _, token), () => memory.WriteString(stringWrite, token)], + "Memory.ResolvePointerChain" => + [ + () => memory.TryResolvePointerChain(chain, out _, out _, token), + () => memory.ResolvePointerChain(chain, token) + ], + "Memory.Read" => + [ + () => memory.TryRead(new MemoryReadRequest(Target, codec), out _, out _, token), + () => memory.Read(new MemoryReadRequest(Target, codec), token) + ], + "Memory.Write" => + [ + () => memory.TryWrite(new MemoryWriteRequest(Target, 1, codec), out _, token), + () => memory.Write(new MemoryWriteRequest(Target, 1, codec), token) + ], + "Patterns.Scan" => + [ + () => clients.Patterns.TryScan(Request(), out _, out _, token), + () => clients.Patterns.Scan(Request(), token), () => clients.Patterns.ScanDetailed(Request(), token) + ], + "ValueScans.CreateSession" => + [ + () => clients.ValueScans.TryCreateSession(out _, out _, token), + () => clients.ValueScans.CreateSession(token) + ], + "ValueScans.FirstScan" => + [ + () => session.TryFirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), out _, token), + () => session.FirstScan(ValueScanFirstRequest.Exact(ValueScanValue.FromInt32(1)), token) + ], + "ValueScans.NextScan" => + [ + () => session.TryNextScan(ValueScanNextRequest.Changed(), out _, token), + () => session.NextScan(ValueScanNextRequest.Changed(), token) + ], + "ValueScans.Reset" => [() => session.TryReset(out _, token), () => session.Reset(token)], + "ValueScans.GetResultCount" => + [() => session.TryGetResultCount(out _, out _, token), () => session.GetResultCount(token)], + "ValueScans.Read" => + [ + () => session.TryRead(new ValueScanReadRequest(0, 8), out _, out _, token), + () => session.Read(new ValueScanReadRequest(0, 8), token) + ], + "Allocations.Allocate" => + [ + () => clients.Allocations.TryAllocate(new AllocationRequest(4096), out _, out _, token), + () => clients.Allocations.Allocate(new AllocationRequest(4096), token) + ], + "Inspection.GetModules" => + [ + () => inspection.TryGetModules(items, null, out _, out _, token), + () => inspection.GetModules(items, null, token) + ], + "Inspection.GetModuleSections" => + [ + () => inspection.TryGetModuleSections(module, items, out _, out _, token), + () => inspection.GetModuleSections(module, items, token) + ], + "Inspection.GetMemoryRegions" => + [ + () => inspection.TryGetMemoryRegions(items, out _, out _, token), + () => inspection.GetMemoryRegions(items, token) + ], + "Inspection.GetMemoryRegion" => + [ + () => inspection.TryGetMemoryRegion(Target, out _, out _, token), + () => inspection.GetMemoryRegion(Target, token) + ], + "Inspection.GetSymbol" => + [ + () => inspection.TryGetSymbol(expression, out _, out _, token), + () => inspection.GetSymbol(expression, token) + ], + "Inspection.ResolveName" => + [ + () => inspection.TryResolveName(Target, out _, out _, token), + () => inspection.ResolveName(Target, token) + ], + "Inspection.RegisterSymbol" => + [ + () => inspection.TryRegisterSymbol(registration, out _, out _, token), + () => inspection.RegisterSymbol(registration, token) + ], + "Inspection.ResolveAddress" => + [ + () => inspection.TryResolveAddress(expression, AddressResolutionMode.Default, out _, out _, token), + () => inspection.ResolveAddress(expression, AddressResolutionMode.Default, token) + ], + "Tables.GetRecordCount" => + [() => tables.TryGetRecordCount(out _, out _, token), () => tables.GetRecordCount(token)], + "Tables.GetSnapshot" => + [() => tables.TryGetSnapshot(records, out _, out _, token), () => tables.GetSnapshot(records, token)], + "Tables.Find" => + [ + () => tables.TryFind(new MemoryRecordSearch("Ammo"), records, out _, out _, token), + () => tables.Find(new MemoryRecordSearch("Ammo"), records, token) + ], + "Tables.GetRecordAt" => + [() => tables.TryGetRecordAt(0, out _, out _, token), () => tables.GetRecordAt(0, token)], + "Tables.GetRecord" => + [() => tables.TryGetRecord(record, out _, out _, token), () => tables.GetRecord(record, token)], + "Tables.GetSelectedRecord" => + [() => tables.TryGetSelectedRecord(out _, out _, token), () => tables.GetSelectedRecord(token)], + "Tables.SelectRecord" => + [() => tables.TrySelectRecord(record, out _, out _, token), () => tables.SelectRecord(record, token)], + "Tables.Create" => + [() => tables.TryCreate(definition, out _, out _, token), () => tables.Create(definition, token)], + "Tables.Update" => + [ + () => tables.TryUpdate(record, new MemoryRecordUpdate("Ammo"), out _, out _, token), + () => tables.Update(record, new MemoryRecordUpdate("Ammo"), token) + ], + "Tables.Delete" => [() => tables.TryDelete(record, out _, token), () => tables.Delete(record, token)], + "Tables.SetActive" => + [ + () => tables.TrySetActive(record, true, out _, out _, token), + () => tables.SetActive(record, true, token) + ], + "Tables.SetParent" => + [ + () => tables.TrySetParent(record, null, out _, out _, token), + () => tables.SetParent(record, null, token) + ], + "Tables.GetHierarchy" => + [ + () => tables.TryGetHierarchy(record, new MemoryRecordHierarchyRequest(8, 2), out _, out _, token), + () => tables.GetHierarchy(record, new MemoryRecordHierarchyRequest(8, 2), token) + ], + "Tables.LoadTrustedTable" => + [ + () => tables.TryLoadTrustedTable(new TableLoadRequest(file), out _, token), + () => tables.LoadTrustedTable(new TableLoadRequest(file), token) + ], + "Tables.SaveTable" => + [ + () => tables.TrySaveTable(new TableSaveRequest(file), out _, token), + () => tables.SaveTable(new TableSaveRequest(file), token) + ], + "Lua.RegisterModule" => + [ + () => clients.Lua.TryRegisterModule(clients.Module, out _, out _, token), + () => clients.Lua.RegisterModule(clients.Module, token) + ], + "Lua.Execute" => + [ + () => clients.Lua.TryExecute(new ConstantOperation(), out _, out _, token), + () => clients.Lua.Execute(new ConstantOperation(), token) + ], + "UnsafeLua.Execute" => + [ + () => clients.UnsafeLua.TryExecute(new LuaScript("return 1"), out _, token), + () => clients.UnsafeLua.Execute(new LuaScript("return 1"), token) + ], + "AutoAssembler.Check" => + [ + () => clients.AutoAssembler.TryCheck(script, out _, out _, token), + () => clients.AutoAssembler.Check(script, token) + ], + "AutoAssembler.ApplyPatch" => + [ + () => clients.AutoAssembler.TryApplyPatch(script, out _, out _, token), + () => clients.AutoAssembler.ApplyPatch(script, token) + ], + "Assembly.Assemble" => + [ + () => assembly.TryAssemble(instruction, out _, out _, token), + () => assembly.Assemble(instruction, token) + ], + "Assembly.Disassemble" => + [() => assembly.TryDisassemble(Target, out _, out _, token), () => assembly.Disassemble(Target, token)], + "Assembly.GetInstructionLength" => + [ + () => assembly.TryGetInstructionLength(Target, out _, out _, token), + () => assembly.GetInstructionLength(Target, token) + ], + "Assembly.GetPreviousInstructionAddress" => + [ + () => assembly.TryGetPreviousInstructionAddress(Target, out _, out _, token), + () => assembly.GetPreviousInstructionAddress(Target, token) + ], + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null) + }; + } + + private static Exception CreateSdkFault(string faultType) + { + return faultType switch + { + nameof(EngineBindingException) => new EngineBindingException("contract.binding", "binding fault"), + nameof(EngineCapabilityUnavailableException) => + new EngineCapabilityUnavailableException("contract.capability", "capability fault"), + nameof(EngineGlobalUnavailableException) => + new EngineGlobalUnavailableException("contract.global", "global fault"), + nameof(EngineLuaException) => new EngineLuaException("contract.lua", LuaStatus.RuntimeError, "lua fault"), + nameof(EngineMarshallingException) => new EngineMarshallingException("contract.marshal", + EngineMarshallingDirection.Result, "integer", "string", "marshalling fault"), + nameof(EngineOperationFailedException) => + new EngineOperationFailedException("contract.operation", "operation fault"), + nameof(LuaException) => new LuaException("lua state fault"), + nameof(InvalidOperationException) => new InvalidOperationException("detached runtime"), + _ => throw new ArgumentOutOfRangeException(nameof(faultType), faultType, null) + }; + } + + /// One fake for every Core port; each call throws the configured SDK fault unless configured otherwise. + private sealed class ThrowingPorts(Exception fault) + : IAobScanPort, IInspectionPort, ITableRecordLookupPort, ITableRecordMutationPort, IMemoryCodecContextPort, + IRuntimeObservationPort, IProcessSelectionPort, IProcessHost, IAutoAssemblerPort, IInstructionPort + { + private int _writes; + + internal int Calls + { + get; + private set; + } + + internal int ReleasedLists + { + get; + private set; + } + + internal int SucceedingWrites + { + get; + init; + } + + internal bool RejectAfterSucceedingWrites + { + get; + init; + } + + internal string[]? ScanResult + { + get; + init; + } + + internal Action? OnScan + { + get; + init; + } + + internal Action? BeforeFault + { + get; + init; + } + + /// Gets the modules the AOB module enumeration returns instead of faulting. + internal ModuleInfo[]? ModuleSnapshot + { + get; + init; + } + + /// Gets the selection the target observation returns instead of faulting. + internal TargetSelectionFacts? Selection + { + get; + init; + } + + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches) + { + Calls++; + OnScan?.Invoke(); + if (ScanResult is { } entries) + { + matches = new ListDouble(entries, () => ReleasedLists++); + return AobHosts.Outcome(AobScanOutcomeKind.Matches, entries.Length); + } + + throw Fault(); + } + + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + throw Fault(); + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + if (ModuleSnapshot is { } modules) + { + Calls++; + modules.CopyTo(destination, 0); + written = modules.Length; + return InspectionStatus.Success; + } + + throw Fault(); + } + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, + out int written) + { + throw Fault(); + } + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, + out int written) + { + throw Fault(); + } + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written) + { + throw Fault(); + } + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region) + { + throw Fault(); + } + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol) + { + throw Fault(); + } + + /// + /// The collision pre-check of a symbol registration finds nothing, so the registration fault is raised by + /// , the CheatEngine.SDK ownership coordinator. + /// + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionMode mode, + out Address address) + { + address = default; + return InspectionStatus.NotFound; + } + + public LuaOperationStatus TryGetName(Address address, out string? name) + { + throw Fault(); + } + + public SymbolRegistrationAttempt TryRegisterOwned(SymbolName name, Address address, + SymbolRegistrationOptions options) + { + throw Fault(); + } + + public RecordLookupStatus TryGetRecord(int index, out MemoryRecordSnapshot record) + { + throw Fault(); + } + + public RecordLookupStatus TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record) + { + throw Fault(); + } + + public RecordLookupStatus TryGetSelected(out MemoryRecordSnapshot record) + { + throw Fault(); + } + + public TableRecordCreation TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record) + { + throw Fault(); + } + + public TableRecordMutationOutcome TryDelete(MemoryRecordId id) + { + throw Fault(); + } + + public TableRecordMutationOutcome TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + throw Fault(); + } + + public TableActivationObservation TrySetActive(MemoryRecordId id, bool requested) + { + throw Fault(); + } + + public TableRecordMutationOutcome TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record) + { + throw Fault(); + } + + public RecordLookupStatus TryGetTable(int maximumItems, out AddressTableSnapshot table) + { + throw Fault(); + } + + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) + { + throw Fault(); + } + + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) + { + throw Fault(); + } + + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize) + { + throw Fault(); + } + + public ProcessOperationStatus TryObserveRuntimeInfo(out RuntimeInfo? info) + { + throw Fault(); + } + + public LuaOperationStatus ObserveHost(out CheatEngineHostObservation host) + { + throw Fault(); + } + + public LuaOperationStatus TryGetCheatEngineFileVersion(out CheatEngineVersion version) + { + throw Fault(); + } + + public LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchitecture architecture) + { + throw Fault(); + } + + public LuaOperationStatus TryIsCheatEngine64Bit(out bool is64Bit) + { + throw Fault(); + } + + public LuaOperationStatus TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem) + { + throw Fault(); + } + + public TargetSelectionFacts ObserveSelection() + { + if (Selection is { } selection) + { + Calls++; + return selection; + } + + throw Fault(); + } + + public TargetIdentityFacts ValidateSelection(TargetProcessIncarnation expected) + { + throw Fault(); + } + + public ProcessOperationStatus SelectAndObserve(TargetProcessId processId, + out CurrentProcessObservation observation) + { + throw Fault(); + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + throw Fault(); + } + + public IReadOnlyList GetLocalProcesses() + { + throw Fault(); + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + throw Fault(); + } + + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure failure) + { + throw Fault(); + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure failure) + { + throw Fault(); + } + + public bool TryReadPrimitive(Address address, out T value, out MemoryAccessFailure failure) + { + throw Fault(); + } + + public bool TryReadPointer(Address address, PointerSize pointerSize, out Address value, + out MemoryAccessFailure failure) + { + throw Fault(); + } + + public bool TryWritePointer(Address address, Address value, PointerSize pointerSize, + out MemoryAccessFailure failure) + { + throw Fault(); + } + + public bool TryWritePrimitive(Address address, T value, out MemoryAccessFailure failure) + { + Calls++; + if (_writes < SucceedingWrites) + { + _writes++; + failure = MemoryAccessFailure.None; + return true; + } + + if (RejectAfterSucceedingWrites) + { + failure = MemoryAccessFailure.WriteFailed; + return false; + } + + throw Fault(); + } + + public bool TryApply(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerApplyFacts facts, out IAutoAssemblerPatchOwner? patch, + out CheatEngineFailure admissionFailure) + { + throw Fault(); + } + + public bool TryCheck(string operation, string script, AutoAssemblerOptions options, + out AutoAssemblerCheckFacts facts, out CheatEngineFailure admissionFailure) + { + throw Fault(); + } + + public bool TryRunAdmitted(string operation, Action work, out CheatEngineFailure admissionFailure) + { + // The fake admits every call; the fault comes from the first instruction call inside it. + admissionFailure = default; + work(); + return true; + } + + public InstructionOperationStatus ObserveProfile(out InstructionProfileObservation profile) + { + throw Fault(); + } + + public InstructionOperationStatus Assemble(InstructionProfileObservation profile, string instruction, + Address address, AssemblePreference preference, bool skipRangeCheck, Span destination, + out int written, out int requiredLength) + { + throw Fault(); + } + + public InstructionOperationStatus Disassemble(InstructionProfileObservation profile, Address address, + int maximumUtf8Bytes, out InstructionDisassembly disassembly) + { + throw Fault(); + } + + public InstructionOperationStatus GetLength(InstructionProfileObservation profile, Address address, + out int length) + { + throw Fault(); + } + + public InstructionOperationStatus GetPrevious(InstructionProfileObservation profile, Address address, + out Address previous) + { + throw Fault(); + } + + private Exception Fault() + { + Calls++; + BeforeFault?.Invoke(); + return fault; + } + } + + private sealed class ListDouble(string[] entries, Action onRelease) : IAobMatchList + { + public bool TryGetCount(out int count) + { + count = entries.Length; + return true; + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + value = entries[index]; + return true; + } + + public TargetReleaseStatus Release() + { + onRelease(); + return TargetReleaseStatus.Released; + } + } + + /// An application-owned exception type, distinct from every Client and SDK exception. + private sealed class ConsumerException(string message) : Exception(message); + + private sealed class ThrowingCodec(Exception fault) : IMemoryCodec + { + public bool TryRead(IMemoryReadContext context, Address address, [MaybeNullWhen(false)] out int value, out CheatEngineFailure failure) + { + failure = default; + throw fault; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) + { + failure = default; + throw fault; + } + } + + private sealed class ThrowingOperation(Exception fault) : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, [MaybeNullWhen(false)] out int result, + out CheatEngineFailure failure) + { + throw fault; + } + } + + /// A Lua module whose registration makes one port call, standing for the SDK work of a generated module. + private sealed class PortBackedModule(ThrowingPorts ports) : ILuaModule + { + public LuaModuleDescriptor Descriptor + { + get; + } = new("contract", [new LuaExportDescriptor("contract_global")]); + + public void Register() + { + _ = ports.ObserveSelection(); + } + + public LuaModuleReleaseOutcome Unregister() + { + return LuaModuleReleaseOutcome.Released("contract", 1, 0, 0); + } + } + + /// One client of every family over the same activation and the same ports. + private sealed class OperationClients(SdkMainThreadDispatcher dispatcher, CoreLifetime lifetime, + ThrowingPorts ports, FakeValueScanPort scans, FakeAllocationPort allocations, IValueScanSession session) + { + internal SdkMainThreadDispatcher Dispatcher + { + get; + } = dispatcher; + + internal RuntimeClient Runtime + { + get; + } = new(dispatcher, lifetime, CoreClientPolicy.SafeDefaults); + + internal ProcessClient Processes + { + get; + } = new(dispatcher, ports, ports, ports, lifetime); + + internal MemoryClient Memory + { + get; + } = new(dispatcher, lifetime, ports); + + internal PatternScanner Patterns + { + get; + } = new(dispatcher, ports); + + internal ValueScanner ValueScans + { + get; + } = new(dispatcher, Binder(dispatcher), scans); + + internal IValueScanSession Session + { + get; + } = session; + + internal AllocationClient Allocations + { + get; + } = new(dispatcher, Binder(dispatcher), allocations); + + internal InspectionClient Inspection + { + get; + } = new(dispatcher, lifetime, ports); + + internal TableClient Tables + { + get; + } = new(dispatcher, CoreClientPolicy.SafeDefaults, ports, lifetime, ports); + + internal LuaClient Lua + { + get; + } = new(dispatcher, lifetime); + + internal PortBackedModule Module + { + get; + } = new(ports); + + internal UnsafeLuaClient UnsafeLua + { + get; + } = new(dispatcher, new CoreClientPolicy([], enableUnsafeLuaExecution: true), lifetime); + + internal AutoAssemblerClient AutoAssembler + { + get; + } = new(dispatcher, AutoAssemblerPolicy(), lifetime, Binder(dispatcher), ports); + + internal AssemblyClient Assembly + { + get; + } = new(dispatcher, lifetime, new MemoryResourceLimits(), ports); + } + + /// Runs every callback inline and counts the dispatches. + private sealed class CountingMainThreadInvoker : IMainThreadInvoker + { + private readonly InlineMainThreadInvoker _inner = new(); + + public int Calls + { + get; + private set; + } + + public Exception? Invoke(Action callback) + { + Calls++; + return _inner.Invoke(callback); + } + + public MainThreadInvocationResult Invoke(Func callback) + { + Calls++; + return _inner.Invoke(callback); + } + } + + private sealed class ConstantOperation : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, [MaybeNullWhen(false)] out int result, + out CheatEngineFailure failure) + { + result = 1; + failure = default; + return true; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs index 7106689..31799cd 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs @@ -49,7 +49,7 @@ public void TryExecuteNormalizesAnOperationFailureWithoutDetails() { LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); - bool succeeded = client.TryExecute(new FailingOperation(default), out _, + bool succeeded = client.TryExecute(new FailingOperation(default), out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -66,7 +66,7 @@ public void ExecuteTurnsAnExpectedFailureIntoTheClientException() LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); CheatEngineOperationException exception = Assert.Throws(() => - client.Execute(new FailingOperation(expected), + client.Execute(new FailingOperation(expected), TestContext.Current.CancellationToken)); Assert.Equal(expected, exception.Failure); @@ -79,10 +79,10 @@ public void TryExecuteThrowsActivationExpiredWhenTheContextCannotEnterTheCurrent LuaClient client = new(new ImmediateDispatcher(), static () => 7, static () => false); CheatEngineActivationExpiredException exception = Assert.Throws(() => - client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); + client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); - Assert.Equal("Lua.OperationContext", exception.Failure.Operation); + Assert.Equal("Lua.Execute", exception.Failure.Operation); Assert.Null(operation.Context); } @@ -95,7 +95,7 @@ public void TryExecuteHonorsCancellationBeforeTheTypedOperationRuns() RetainingOperation operation = new(); LuaClient client = new(dispatcher, static () => 7, static () => true); - bool succeeded = client.TryExecute( + bool succeeded = client.TryExecute( operation, out _, out CheatEngineFailure failure, @@ -227,7 +227,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -238,7 +238,7 @@ public TResult Invoke(Func callback, CancellationToken cancell return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } @@ -283,7 +283,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -294,7 +294,7 @@ public TResult Invoke(Func callback, CancellationToken cancell return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs index 070737c..80fba67 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs @@ -29,7 +29,6 @@ public void TryRegisterModuleTracksItsLeaseAndDisposesItThroughTheMainThreadDisp Assert.True(succeeded); Assert.Equal(default, failure); Assert.NotNull(lease); - Assert.Equal(81, lease.Epoch); Assert.False(lease.IsReleased); Assert.Equal(["diagnostics.register"], module.Events); Assert.Equal(1, dispatcher.InvocationCount); @@ -69,7 +68,7 @@ public void TryRegisterModuleRejectsTheSameModuleInstanceUntilItsLeaseIsReleased } [Fact] - public void TryRegisterModuleAllowsSeparateManualModulesBecauseTheyDoNotClaimGlobalNames() + public void TryRegisterModuleAllowsModulesWhoseDescriptorsDoNotOverlap() { ImmediateDispatcher dispatcher = new(); RecordingModule first = new("first"); @@ -97,7 +96,7 @@ public void TryRegisterModuleRejectsAnAlreadyReservedDescribedModuleIdentityBefo Assert.False(succeeded); Assert.Null(lease); - Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); Assert.Equal("Lua.RegisterModule", failure.Operation); Assert.Contains("identity 'diagnostics'", failure.Message, StringComparison.Ordinal); Assert.Equal(["first.register"], first.Events); @@ -119,7 +118,7 @@ public void TryRegisterModuleRejectsAnAlreadyReservedDescribedExportBeforeLuaMut Assert.False(succeeded); Assert.Null(lease); - Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); Assert.Contains("export 'diagnostics'", failure.Message, StringComparison.Ordinal); Assert.Equal(["first.register"], first.Events); Assert.Empty(second.Events); @@ -148,7 +147,10 @@ public void FailedDescribedRegistrationReleasesItsNameReservationForTheNextModul public void TryRegisterModuleReleasesTrackedReservationWhenDispatcherRejectsRegistration() { CheatEngineFailure dispatchFailure = new(CheatEngineFailureKind.InvalidState, "Test.Dispatcher", "Rejected."); - ImmediateDispatcher dispatcher = new() { TryInvokeFailure = dispatchFailure }; + ImmediateDispatcher dispatcher = new() + { + TryInvokeFailure = dispatchFailure + }; List tracked = []; int trackCount = 0; int untrackCount = 0; @@ -188,7 +190,7 @@ public void TryRegisterModuleReleasesTrackedReservationWhenDispatcherRejectsRegi } [Fact] - public async Task ConcurrentDescribedRegistrationRejectsTheSecondModuleBeforeEitherOfItsLuaExportsMutate() + public async Task ConcurrentDescribedRegistrationRejectsTheSecondModuleBeforeEitherOfItsLuaExportsMutateAsync() { using BlockingDispatcher dispatcher = new(); DescribedRecordingModule first = new("first", "one", ["diagnostics"]); @@ -207,7 +209,7 @@ public async Task ConcurrentDescribedRegistrationRejectsTheSecondModuleBeforeEit Assert.True(await firstRegistration); Assert.False(secondSucceeded); Assert.Null(secondLease); - Assert.Equal(CheatEngineFailureKind.InvalidState, secondFailure.Kind); + Assert.Equal(CheatEngineFailureKind.OperationRejected, secondFailure.Kind); Assert.Empty(second.Events); Assert.Equal(["first.register"], first.Events); firstLease!.Dispose(); @@ -244,7 +246,7 @@ public void RegisterModuleReturnsTheLeaseAndThrowsTheMappedFailureForARejectedRe CheatEngineOperationException exception = Assert.Throws(() => client.RegisterModule(rejected, TestContext.Current.CancellationToken)); - Assert.Equal(81, lease.Epoch); + Assert.False(lease.IsReleased); Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); Assert.Equal("Lua.RegisterModule", exception.Failure.Operation); Assert.Equal(["accepted.register"], accepted.Events); @@ -379,7 +381,7 @@ public void TryRegisterModuleDefersARegistrationCompletedDuringShutdownToMainThr Assert.Null(lease); Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); Assert.Equal("Lua.RegisterModule", failure.Operation); - Assert.IsType(failure.Exception); + Assert.IsType(failure.Exception); Assert.Equal(["diagnostics.register"], module.Events); Assert.Equal(1, invoker.ActionCalls); @@ -389,7 +391,9 @@ public void TryRegisterModuleDefersARegistrationCompletedDuringShutdownToMainThr } Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); - Assert.Equal(2, invoker.ActionCalls); + // Registration is an action; the lease release is a function whose outcome the lease records. + Assert.Equal(1, invoker.ActionCalls); + Assert.Equal(1, invoker.FunctionCalls); } [Fact] @@ -465,13 +469,19 @@ public void TryRegisterModulePreservesADeferredUnregistrationFailureFromShutdown using (lifetime.EnterCleanupScope()) { - InvalidOperationException cleanupException = Assert.Throws( + // The module threw from Unregister: the lease records an unconfirmed cleanup and the drain reports it with + // safe fields only. + CheatEngineOperationException report = Assert.Throws( lifetime.DrainOwnedResourcesForDisable); - Assert.Equal("generated unregistration failed", cleanupException.Message); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, report.Failure.Kind); + Assert.Equal("Lua.Release", report.Failure.Operation); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, report.Failure.HostEffect); } Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); - Assert.Equal(2, invoker.ActionCalls); + // Registration is an action; the lease release is a function whose outcome the lease records. + Assert.Equal(1, invoker.ActionCalls); + Assert.Equal(1, invoker.FunctionCalls); } [Fact] @@ -504,81 +514,236 @@ public void ForgottenModuleLeasesReleaseInReverseRegistrationOrderFromTheActivat } [Fact] - public void StaleModuleLeaseDoesNotAttemptToDispatchIntoAChangedActivation() + public void ALeaseWhoseReleaseCannotBeDispatchedStaysActiveAndTrackedForTheCleanupRetry() { + CheatEngineFailure refused = new(CheatEngineFailureKind.ActivationExpired, "Test.Dispatcher", "Refused."); ImmediateDispatcher dispatcher = new(); - bool activationIsCurrent = true; + List tracked = []; RecordingModule module = new("diagnostics"); - LuaClient client = CreateClient(dispatcher, () => activationIsCurrent); - + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, TestContext.Current.CancellationToken)); - activationIsCurrent = false; + dispatcher.TryInvokeFailure = refused; + LeaseReleaseOutcome unavailable = lease.Release(); lease.Dispose(); - Assert.True(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + unavailable); + Assert.False(lease.IsReleased); + Assert.Single(tracked, lease); + Assert.Null(lease.LastModuleReleaseOutcome); Assert.Equal(["diagnostics.register"], module.Events); - Assert.Equal(1, dispatcher.InvocationCount); + + dispatcher.TryInvokeFailure = null; + LeaseReleaseOutcome released = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), released); + Assert.True(lease.IsReleased); + Assert.Empty(tracked); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); } [Fact] - public void DispatcherRejectionLeavesTheLeaseTrackedForTheLaterCleanupDispatch() + public void AnUnregistrationExceptionIsAnUnconfirmedCleanupThatDisposeNeverThrowsAndNeverRetries() { ImmediateDispatcher dispatcher = new(); List tracked = []; - RecordingModule module = new("diagnostics"); + RecordingModule module = new("diagnostics", unregisterFailureCount: 1); + RecordingModule replacement = new("diagnostics"); LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => { tracked.Remove(lease); }); + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + + lease.Dispose(); + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Unknown), + lease.LastReleaseOutcome); + Assert.Null(lease.LastModuleReleaseOutcome); + // Incomplete: the activation keeps tracking it so the deactivation report carries it. + Assert.Single(tracked, lease); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + // The lease ended, so its names are free again. + using ILuaModuleLease replacementLease = + client.RegisterModule(replacement, TestContext.Current.CancellationToken); + } + [Theory] + [InlineData(LeaseReleaseKind.PartiallyReleased, CheatEngineHostEffect.Started)] + [InlineData(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted)] + public void AReleaseThatRequiresManualRecoveryEndsTheLeaseAndStaysTrackedForTheReport(LeaseReleaseKind kind, + CheatEngineHostEffect hostEffect) + { + ImmediateDispatcher dispatcher = new(); + List tracked = []; + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, TestContext.Current.CancellationToken)); - dispatcher.InvokeException = - new CheatEngineClientLifecycleException("Dispatcher.Invoke", "Activation stopping."); + LuaModuleReleaseOutcome reported = kind == LeaseReleaseKind.PartiallyReleased + ? LuaModuleReleaseOutcome.PartiallyReleased("diagnostics", 0, 0, 0, ["diagnostics_global"]) + : LuaModuleReleaseOutcome.RefusedRuntimeChanged("diagnostics", 1); + module.NextOutcome = reported; - Assert.Throws(lease.Dispose); + LeaseReleaseOutcome outcome = lease.Release(); - Assert.False(lease.IsReleased); + Assert.Equal(new LeaseReleaseOutcome(kind, hostEffect), outcome); + Assert.True(outcome.RequiresManualRecovery); + Assert.True(lease.IsReleased); + Assert.Same(reported, lease.LastModuleReleaseOutcome); Assert.Single(tracked, lease); - Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(outcome, lease.Release()); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + } - dispatcher.InvokeException = null; - lease.Dispose(); + [Fact] + public void AnUnconfirmedModuleReleaseEndsTheLeaseAndTheActivationCleanupReportsIt() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new RecordingMainThreadInvoker()); + RecordingModule module = new("diagnostics"); + LuaClient client = new(dispatcher, lifetime); + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + // What a generated module reports when CheatEngine.SDK consumed its registration lease but returned a release + // outside the documented shape. + LuaModuleReleaseOutcome unconfirmed = + LuaModuleReleaseOutcome.Create("diagnostics", LeaseReleaseKind.CleanupUnconfirmed, 0, 0, 0, 1, []); + module.NextOutcome = unconfirmed; + LeaseReleaseOutcome outcome = lease.Release(); + LeaseReleaseOutcome retry = lease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), outcome); + Assert.False(outcome.IsRetryable); Assert.True(lease.IsReleased); - Assert.Empty(tracked); + Assert.Same(unconfirmed, lease.LastModuleReleaseOutcome); + Assert.Equal(outcome, retry); + context.Stop(); + using (lifetime.EnterCleanupScope()) + { + // The lease stayed with the activation, so the drain reports the incomplete release instead of retrying it. + CheatEngineOperationException report = Assert.Throws( + lifetime.DrainOwnedResourcesForDisable); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, report.Failure.Kind); + Assert.Equal("Lua.Release", report.Failure.Operation); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, report.Failure.HostEffect); + Assert.Contains(nameof(LeaseReleaseKind.CleanupUnconfirmed), report.Failure.Message, StringComparison.Ordinal); + } + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); } [Fact] - public void FailedLeaseDisposeRemainsTrackedAndCanBeRetriedByTheActivationCleanupPath() + public void TheLeaseIsAClientLeaseThatReportsWhatTheModuleObserved() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true); + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + LuaModuleReleaseOutcome reported = LuaModuleReleaseOutcome.Released("diagnostics", 0, 0, 1); + module.NextOutcome = reported; + + ICheatEngineLease clientLease = lease; + Assert.Null(clientLease.LastReleaseOutcome); + LeaseReleaseOutcome outcome = clientLease.Release(); + + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), outcome); + Assert.Equal(outcome, clientLease.LastReleaseOutcome); + Assert.Same(reported, lease.LastModuleReleaseOutcome); + Assert.Equal(1, lease.LastModuleReleaseOutcome!.ReplacementCount); + } + + [Fact] + public void TryRegisterModuleReportsTheFailureAClassifiedRegistrationRefusalCarries() + { + ImmediateDispatcher dispatcher = new(); + CheatEngineFailure refusal = new(CheatEngineFailureKind.OperationRejected, "Lua.RegisterModule", + "Lua global 'diagnostics_global' is already defined.", null, CheatEngineHostEffect.NotApplied); + RecordingModule module = new("diagnostics", refusal.ToException(TestContext.Current.CancellationToken)); + LuaClient client = CreateClient(dispatcher, static () => true); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(refusal, failure); + } + + [Fact] + public void TryRegisterModuleRefusesAModuleWithoutADescriptorBeforeAnyDispatch() + { + ImmediateDispatcher dispatcher = new(); + LuaClient client = CreateClient(dispatcher, static () => true); + + ArgumentException exception = Assert.Throws(() => + client.TryRegisterModule(new UndescribedModule(), out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal("luaModule", exception.ParamName); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void AReleaseThatCouldNotBeginKeepsTheLeaseForTheCleanupRetry() { ImmediateDispatcher dispatcher = new(); List tracked = []; - RecordingModule module = new("diagnostics", unregisterFailureCount: 1); + RecordingModule module = new("diagnostics"); + RecordingModule contender = new("diagnostics"); LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => { tracked.Remove(lease); }); - Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, TestContext.Current.CancellationToken)); + LuaModuleReleaseOutcome unavailable = LuaModuleReleaseOutcome.CleanupUnavailable("diagnostics", 1); + module.NextOutcome = unavailable; - InvalidOperationException exception = Assert.Throws(lease.Dispose); + LeaseReleaseOutcome refused = lease.Release(); - Assert.Equal("generated unregistration failed", exception.Message); + Assert.Equal(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted), + refused); + Assert.True(refused.IsRetryable); Assert.False(lease.IsReleased); + Assert.Same(unavailable, lease.LastModuleReleaseOutcome); Assert.Single(tracked, lease); - Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + // The module still owns its registration, so its names stay reserved. + Assert.False(client.TryRegisterModule(contender, out _, out _, TestContext.Current.CancellationToken)); lease.Dispose(); Assert.True(lease.IsReleased); Assert.Empty(tracked); Assert.Equal(["diagnostics.register", "diagnostics.unregister", "diagnostics.unregister"], module.Events); - Assert.Equal(3, dispatcher.InvocationCount); + } + + private sealed class UndescribedModule : ILuaModule + { + public LuaModuleDescriptor Descriptor => default; + + public void Register() + { + throw new InvalidOperationException("A module without a descriptor must never be registered."); + } + + public LuaModuleReleaseOutcome Unregister() + { + throw new InvalidOperationException("A module without a descriptor must never be released."); + } } private static LuaClient CreateClient( @@ -607,6 +772,17 @@ internal List Events get; } = events ?? []; + internal LuaModuleReleaseOutcome? NextOutcome + { + get; + set; + } + + public LuaModuleDescriptor Descriptor + { + get; + } = new(name, [new LuaExportDescriptor(name + "_global")]); + public void Register() { Events.Add(name + ".register"); @@ -617,17 +793,21 @@ public void Register() } } - public void Unregister() + public LuaModuleReleaseOutcome Unregister() { Events.Add(name + ".unregister"); if (_remainingUnregisterFailures-- > 0) { throw new InvalidOperationException("generated unregistration failed"); } + + LuaModuleReleaseOutcome outcome = NextOutcome ?? LuaModuleReleaseOutcome.Released(name, 1, 0, 0); + NextOutcome = null; + return outcome; } } - private sealed class DescribedRecordingModule : IDescribedLuaModule + private sealed class DescribedRecordingModule : ILuaModule { private readonly RecordingModule _inner; @@ -655,9 +835,9 @@ public void Register() _inner.Register(); } - public void Unregister() + public LuaModuleReleaseOutcome Unregister() { - _inner.Unregister(); + return _inner.Unregister(); } } @@ -669,12 +849,6 @@ public int InvocationCount private set; } - internal Exception? InvokeException - { - get; - set; - } - internal CheatEngineFailure? TryInvokeFailure { get; @@ -731,25 +905,20 @@ public bool TryInvoke(Func callback, out TResult result, out C public void Invoke(Action callback, CancellationToken cancellationToken = default) { - if (InvokeException is not null) - { - throw InvokeException; - } - if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } public TResult Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + if (TryInvoke(callback, out TResult? result, out CheatEngineFailure failure, cancellationToken)) { return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } } @@ -795,7 +964,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul { if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) { - failure.Throw(); + failure.Throw(cancellationToken); } } @@ -806,7 +975,7 @@ public TResult Invoke(Func callback, CancellationToken cancell return result; } - failure.Throw(); + failure.Throw(cancellationToken); return default!; } @@ -835,6 +1004,12 @@ internal int ActionCalls private set; } + internal int FunctionCalls + { + get; + private set; + } + public Exception? Invoke(Action callback) { ArgumentNullException.ThrowIfNull(callback); @@ -853,6 +1028,7 @@ internal int ActionCalls public MainThreadInvocationResult Invoke(Func callback) { ArgumentNullException.ThrowIfNull(callback); + FunctionCalls++; try { return new MainThreadInvocationResult(callback(), null); diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleReleaseMappingTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleReleaseMappingTests.cs new file mode 100644 index 0000000..529528f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleReleaseMappingTests.cs @@ -0,0 +1,49 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +/// The Lua module lease maps every release kind a module can report, and an unknown kind fails closed. +public sealed class LuaModuleReleaseMappingTests +{ + private static readonly Dictionary Table = new() + { + [LeaseReleaseKind.Unknown] = Outcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown), + [LeaseReleaseKind.Released] = Outcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed), + [LeaseReleaseKind.AlreadyReleased] = Outcome(LeaseReleaseKind.AlreadyReleased, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.PartiallyReleased] = + Outcome(LeaseReleaseKind.PartiallyReleased, CheatEngineHostEffect.Started), + [LeaseReleaseKind.Replaced] = Outcome(LeaseReleaseKind.Replaced, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.Superseded] = Outcome(LeaseReleaseKind.Superseded, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.ExternallyRemoved] = + Outcome(LeaseReleaseKind.ExternallyRemoved, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.RefusedTargetNotAttached] = + Outcome(LeaseReleaseKind.RefusedTargetNotAttached, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.RefusedTargetChanged] = + Outcome(LeaseReleaseKind.RefusedTargetChanged, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.RefusedTargetIdentityUnavailable] = + Outcome(LeaseReleaseKind.RefusedTargetIdentityUnavailable, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.RefusedRuntimeChanged] = + Outcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted), + [LeaseReleaseKind.CleanupUnconfirmed] = + Outcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started), + [LeaseReleaseKind.CleanupUnavailable] = + Outcome(LeaseReleaseKind.CleanupUnavailable, CheatEngineHostEffect.NotStarted) + }; + + [Fact] + public void EveryModuleReleaseKindIsMappedAndAnUnknownKindFailsClosed() + { + MappingTotality.AssertTotal( + static kind => Table.TryGetValue(kind, out LeaseReleaseOutcome expected) && + LuaModuleReleaseMapping.ToLeaseOutcome(kind) == expected, + static kind => LuaModuleReleaseMapping.ToLeaseOutcome(kind) == + Outcome(LeaseReleaseKind.Unknown, CheatEngineHostEffect.Unknown)); + } + + private static LeaseReleaseOutcome Outcome(LeaseReleaseKind kind, CheatEngineHostEffect hostEffect) + { + return new LeaseReleaseOutcome(kind, hostEffect); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs index 8c1c020..bf83c14 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs @@ -19,7 +19,7 @@ public void TryExecuteRejectsUnsafeLuaBeforeDispatchWhenTheActivationDidNotOptIn Assert.False(succeeded); Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); - Assert.Equal("Lua.ExecuteUnsafe", failure.Operation); + Assert.Equal("UnsafeLua.Execute", failure.Operation); Assert.Equal(0, dispatcher.InvocationCount); } @@ -34,6 +34,36 @@ public void TryExecuteRejectsTheDefaultLuaScriptBeforeCheckingOrDispatchingUnsaf Assert.Equal(0, dispatcher.InvocationCount); } + [Fact] + public void TryExecuteReportsARefusedLuaAdmissionFromTheSdkStatusWithoutRunningTheScript() + { + RecordingDispatcher dispatcher = new(); + UnsafeLuaClient client = new(dispatcher, new CoreClientPolicy([], true)); + + // No Lua runtime is attached in unit tests: CheatEngine.SDK reports the admission as Detached. + bool succeeded = client.TryExecute(new LuaScript("return 42"), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(1, dispatcher.InvocationCount); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Equal("UnsafeLua.Execute", failure.Operation); + Assert.Null(failure.Exception); + Assert.NotEqual(CheatEngineFailureKind.OperationRejected, failure.Kind); + } + + [Fact] + public void ExecuteThrowsTheActivationExpiredExceptionForARefusedLuaAdmission() + { + UnsafeLuaClient client = new(new RecordingDispatcher(), new CoreClientPolicy([], true)); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + client.Execute(new LuaScript("return 42"), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineHostEffect.NotStarted, exception.Failure.HostEffect); + } + private sealed class RecordingDispatcher : ICheatEngineDispatcher { public int InvocationCount diff --git a/tests/CheatEngine.Client.Core.Tests/Qualification/HostQualificationGateTests.cs b/tests/CheatEngine.Client.Core.Tests/Qualification/HostQualificationGateTests.cs new file mode 100644 index 0000000..e6ae1ec --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Qualification/HostQualificationGateTests.cs @@ -0,0 +1,213 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Qualification; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Qualification; + +/// +/// The qualification gate of a capability is derived from the embedded host evidence only: Satisfied for the exact +/// tuple of a recorded run in which every required scenario passed without a waiver, and Unknown, naming the first +/// condition that does not hold, for anything else, including evidence that names another tuple than this build's. +/// +public sealed class HostQualificationGateTests +{ + private const string NoEvidence = "no evidence"; + private const string RunId = "20260930T101530Z-a1b2"; + private const string SdkIdentity = "2.0.0+325c47b573f8bd39a247f1d0101f110fa36c1696"; + + private static readonly HostQualificationContext Exact = new(true, SdkIdentity, CheatEngineVersion.Ce77010621, + PointerSize.Bit64, CheatEngineOperatingSystem.Windows, TargetBackend.LocalProcess, CheatEngineArchitecture.X64, + "1.0.0"); + + private static ClientCapabilityDescriptor TypedMemory => + ClientCapabilityCatalog.Entries.Single(static entry => entry.Id == ClientCapabilityId.TypedMemory); + + [Fact] + public void TheEmbeddedEvidenceIsEmptyUntilARunIsRecorded() + { + Assert.Null(HostQualificationEvidence.Recorded); + } + + [Fact] + public void OnlyTheExactTupleOfARecordedRunIsSatisfied() + { + ClientCapabilityEvidenceGate gate = HostQualificationGate.Evaluate(TypedMemory, Evidence(), Exact, NoEvidence); + + Assert.Equal(ClientCapabilityEvidenceState.Satisfied, gate.State); + Assert.Contains(RunId, gate.Reason, StringComparison.Ordinal); + Assert.Contains(SdkIdentity, gate.Reason, StringComparison.Ordinal); + Assert.Contains(ConsumedSdkIdentity.SupportedHostProfileId, gate.Reason, StringComparison.Ordinal); + } + + [Fact] + public void WithoutEvidenceTheGateIsUnknownWithTheGivenReason() + { + ClientCapabilityEvidenceGate gate = HostQualificationGate.Evaluate(TypedMemory, null, Exact, NoEvidence); + + Assert.Equal((ClientCapabilityEvidenceState.Unknown, NoEvidence), (gate.State, gate.Reason)); + } + + [Fact] + public void UnsafeLuaExecutionIsNeverQualified() + { + ClientCapabilityDescriptor unsafeLua = + ClientCapabilityCatalog.Entries.Single(static entry => entry.Id == ClientCapabilityId.UnsafeLuaExecution); + + ClientCapabilityEvidenceGate gate = HostQualificationGate.Evaluate(unsafeLua, Evidence(), Exact, NoEvidence); + + Assert.Equal(ClientCapabilityEvidenceState.Unknown, gate.State); + Assert.Contains("never host-qualified", gate.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("identity", "(" + SdkIdentity + ") is not exactly the CheatEngine.SDK package")] + [InlineData("no-loaded-identity", "not the loaded CheatEngine.SDK.Engine (no informational version)")] + [InlineData("version", "is not Cheat Engine 7.7.0.10621 64-bit on Windows")] + [InlineData("unknown-version", "is not Cheat Engine 7.7.0.10621 64-bit on Windows")] + [InlineData("bitness", "(observed 7.7.0.10621, an unknown width, Windows)")] + [InlineData("32-bit", "(observed 7.7.0.10621, 32-bit, Windows)")] + [InlineData("operating-system", "is not Cheat Engine 7.7.0.10621 64-bit on Windows")] + [InlineData("backend", "reached through FileAsProcess")] + [InlineData("client-version", "is not the Client 1.0.0")] + [InlineData("no-client-version", "is not the Client 1.0.0")] + [InlineData("architecture", "on a X86 target")] + public void EveryOtherHostOrTargetIsUnknownWithItsReason(string mismatch, string reason) + { + HostQualificationContext context = mismatch switch + { + "identity" => Exact with + { + ExactReviewedIdentity = false + }, + "no-loaded-identity" => Exact with + { + SdkInformationalVersion = null + }, + "version" => Exact with + { + CheatEngineVersion = new CheatEngineVersion(7, 6, 0, 0) + }, + "unknown-version" => Exact with + { + CheatEngineVersion = null + }, + "bitness" => Exact with + { + CheatEngineBitness = PointerSize.Unknown + }, + "32-bit" => Exact with + { + CheatEngineBitness = PointerSize.Bit32 + }, + "operating-system" => Exact with + { + OperatingSystem = CheatEngineOperatingSystem.Linux + }, + "backend" => Exact with + { + Backend = TargetBackend.FileAsProcess + }, + "client-version" => Exact with + { + ClientVersion = "1.0.1" + }, + "no-client-version" => Exact with + { + ClientVersion = null + }, + "architecture" => Exact with + { + TargetArchitecture = CheatEngineArchitecture.X86 + }, + _ => throw new ArgumentOutOfRangeException(nameof(mismatch), mismatch, null) + }; + + ClientCapabilityEvidenceGate gate = HostQualificationGate.Evaluate(TypedMemory, Evidence(), context, NoEvidence); + + Assert.Equal(ClientCapabilityEvidenceState.Unknown, gate.State); + Assert.Contains(reason, gate.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("sdk", "used CheatEngine.SDK 2.0.1+325c47b573f8bd39a247f1d0101f110fa36c1696, not the loaded " + + "CheatEngine.SDK.Engine (" + SdkIdentity + ")")] + [InlineData("sdk-commit", "used CheatEngine.SDK 2.0.0+0000000000000000000000000000000000000000")] + [InlineData("cheat-engine", "recorded Cheat Engine 7.6.0.0; a qualification covers Cheat Engine 7.7.0.10621 only")] + [InlineData("host-profile", "recorded the host profile ce-7.7.0.10621-x86-managed-hostfxr; this Client supports " + + "ce-7.7.0.10621-x64-managed-hostfxr only")] + public void EvidenceOfAnotherTupleIsUnknownWithItsReason(string mismatch, string reason) + { + HostQualificationRecord evidence = mismatch switch + { + "sdk" => Evidence() with + { + SdkInformationalVersion = "2.0.1+325c47b573f8bd39a247f1d0101f110fa36c1696" + }, + "sdk-commit" => Evidence() with + { + SdkInformationalVersion = "2.0.0+0000000000000000000000000000000000000000" + }, + "cheat-engine" => Evidence() with + { + CheatEngineVersion = new CheatEngineVersion(7, 6, 0, 0) + }, + "host-profile" => Evidence() with + { + HostProfile = "ce-7.7.0.10621-x86-managed-hostfxr" + }, + _ => throw new ArgumentOutOfRangeException(nameof(mismatch), mismatch, null) + }; + + ClientCapabilityEvidenceGate gate = HostQualificationGate.Evaluate(TypedMemory, evidence, Exact, NoEvidence); + + Assert.Equal(ClientCapabilityEvidenceState.Unknown, gate.State); + Assert.Contains(reason, gate.Reason, StringComparison.Ordinal); + } + + [Fact] + public void ACapabilityTheRunDidNotRecordIsUnknown() + { + ClientCapabilityDescriptor tables = ClientCapabilityCatalog.Entries.Single(static entry => entry.Id == ClientCapabilityId.Tables); + + ClientCapabilityEvidenceGate gate = HostQualificationGate.Evaluate(tables, Evidence(), Exact, NoEvidence); + + Assert.Equal(ClientCapabilityEvidenceState.Unknown, gate.State); + Assert.Contains($"recorded no qualification of {ClientCapabilityId.Tables.Value}", gate.Reason, StringComparison.Ordinal); + } + + [Fact] + public void AMissingOrWaivedScenarioNeverQualifies() + { + ClientCapabilityEvidenceGate missing = HostQualificationGate.Evaluate(TypedMemory, Evidence(passed: ["Q20", "Q21"]), + Exact, NoEvidence); + ClientCapabilityEvidenceGate waived = HostQualificationGate.Evaluate(TypedMemory, + Evidence(passed: ["Q20", "Q21", "Q33"], waived: ["Q33"]), Exact, NoEvidence); + + Assert.Equal(ClientCapabilityEvidenceState.Unknown, missing.State); + Assert.Contains("Scenario Q33 of Client.TypedMemory did not pass", missing.Reason, StringComparison.Ordinal); + Assert.Equal(ClientCapabilityEvidenceState.Unknown, waived.State); + Assert.Contains("is waived", waived.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("1.0.0+0123456789abcdef", "1.0.0")] + [InlineData("1.0.0-rc.1", "1.0.0-rc.1")] + [InlineData("", null)] + [InlineData(null, null)] + public void TheClientVersionDropsItsBuildMetadata(string? informational, string? expected) + { + Assert.Equal(expected, HostQualificationGate.WithoutMetadata(informational)); + } + + private static HostQualificationRecord Evidence(string[]? passed = null, string[]? waived = null) + { + return new HostQualificationRecord(RunId, "1.0.0", SdkIdentity, ConsumedSdkIdentity.SupportedHostProfileId, + CheatEngineVersion.Ce77010621, + [ + new HostQualifiedCapability(ClientCapabilityId.TypedMemory, [.. passed ?? ["Q20", "Q21", "Q33"]], [.. waived ?? []], + [CheatEngineArchitecture.X64]) + ]); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/README.md b/tests/CheatEngine.Client.Core.Tests/README.md index 5dd0f0a..5b77b58 100644 --- a/tests/CheatEngine.Client.Core.Tests/README.md +++ b/tests/CheatEngine.Client.Core.Tests/README.md @@ -11,8 +11,12 @@ Core owns the behavior that is easy to get wrong at the SDK boundary: dispatch a selection changes, LIFO resource cleanup, failure mapping, protected Lua calls and module leases, memory codecs, AOB result ownership, process/runtime adapters, table mutations, and symbol-registration cleanup. -It also verifies the conservative value-scan gate and its managed state machine. It does not fabricate SDK-owned scan -handles or present an unvalidated Cheat Engine 7.7 x64 value-scan lifecycle as supported. +It also runs the value-scan battery of the audit (chapter 13) against a scripted port that emulates CheatEngine.SDK's +scan session: results, cancellation, target and runtime changes, re-entrant calls and releases. It does not fabricate +SDK-owned scan handles or present an unvalidated Cheat Engine 7.7 x64 value-scan lifecycle as supported. +`Domains/Allocations` runs the allocation lifecycle against a scripted CheatEngine.SDK allocator the same way: the +published lease and its one release, the refusal after a target change or a reused process identifier, which frees +nothing in the new target (Q30), the compensation of an allocation that got no owner, and cancellation. ## How it helps improve CheatEngine.Client @@ -20,6 +24,38 @@ The suite separates deterministic policy tests from live-host validation. That l rollback behavior, bounded materialization, and stale-resource rejection without mocking SDK statics or requiring a user process. Regressions in lifecycle code fail quickly before they can leak into a plugin activation. +Three suites guard the SDK boundary contracts. `Infrastructure/TryContractTests` checks that no SDK exception crosses a +`Try*` method, that an expired activation is never reclassified, and that consumer exceptions are rethrown as the same +instance. `Infrastructure/OwnershipHandoffTests` checks that an SDK owner is released exactly once when publication +fails. `SdkContract/SdkMappingContractTests` checks that every status value and exception type of the consumed SDK +that the Client translates maps to a known Client failure kind. + +The runtime and target suites pin the observed-fact model: `Domains/TargetArchitectureObserverTests` and +`Domains/RuntimeClientTests` derive the ISA from the family facts with the PID read first and never from the 64-bit +fact alone (Q31, Q32), `Domains/ProcessClientTests` keeps the selection identity and the observed width, +`Domains/MemoryPointerWidthTests` refuses pointer-typed paths on a configured/process width mismatch and keeps the codec +width at the process width, and `RuntimeClientTests` also locks the package, qualification and read-only probe gates +(Q44, Q45). `Infrastructure/ConsumedSdkIdentityTests` pins the package gate's version rule: a loaded CheatEngine.SDK of +the supported major at or above the pin, by SemVer precedence, is `Satisfied`, exact or not; another major or an older +version is `Missing`; a missing or malformed version is `Unknown`. `Domains/TableClientGenerationTests` and +`Domains/TableClientMutationTests` refuse record identifiers captured before a trusted table load and report factual +activation outcomes (Q34, Q35); +`Domains/InspectionClientBehaviorTests` and `Domains/SymbolRegistrationLeaseTests` check the symbol collision preflight, +the registration through a fake CheatEngine.SDK ownership coordinator (handoff, supersession, activation reservation) +and the mapping of every SDK release kind onto a lease whose `Dispose` never throws (Q16.b, Q43); +`Domains/InspectionMappingTests` proves the symbol registry status mappings total. `Infrastructure/CoreDiagnosticsTests` runs one operation of every emitting domain and +proves that diagnostic events are never emitted inside a dispatched callback, carry only closed names, counts and +epochs (Q46), and that a throwing sink changes no result. + +`Composition/FluentAobTerminalCompositionTests` compiles in the Fluent AOB terminals (`libs/CheatEngine.Client.Fluent/Scanning`) +and runs `FirstOrNone`, `RequireSingle` and `Take` against the real `PatternScanner` on the bounded, managed-filter and +unscoped routes: factual zeros, `nil` results, a bounded destination full of rows outside the request, and a match next +to rows that straddle the module end, which `RequireSingle` never reports as ambiguous. + +Tests that serve as qualification evidence carry a `Qualification` trait (Q16, Q16.b, Q21, Q27, Q28, Q29, Q31, Q32, Q33, Q34, +Q35, Q43, Q44, Q45, Q46, Q48), so a Q filter selects them. They are C1 evidence (managed tests with doubles), never a +Cheat Engine host result. + ## Run From the repository root: diff --git a/tests/CheatEngine.Client.Core.Tests/SdkContract/SdkMappingContractTests.cs b/tests/CheatEngine.Client.Core.Tests/SdkContract/SdkMappingContractTests.cs new file mode 100644 index 0000000..2438bf6 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/SdkContract/SdkMappingContractTests.cs @@ -0,0 +1,288 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Hosting.Plugin; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.Core.Tests.SdkContract; + +/// +/// Proves that every SDK enum value and exception type the Client translates maps to a known Client failure kind (Q48, +/// A11-30): a new value or type in a candidate SDK package fails here instead of silently becoming Unknown. +/// +public sealed class SdkMappingContractTests +{ + /// + /// The Client failure kind and host effects of every SDK memory failure (plan L10). A pointer value wider than the + /// target is refused after Cheat Engine returned it on a read, and before Cheat Engine is called on a write. + /// + private static Dictionary ExpectedMemoryFailures => new() + { + [MemoryAccessFailure.None] = new ExpectedMemoryFailure(CheatEngineFailureKind.IndeterminateHostResult, + CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown), + [MemoryAccessFailure.GlobalUnavailable] = new ExpectedMemoryFailure( + CheatEngineFailureKind.CapabilityUnavailable, CheatEngineHostEffect.NotStarted, + CheatEngineHostEffect.NotStarted), + [MemoryAccessFailure.LuaError] = new ExpectedMemoryFailure(CheatEngineFailureKind.LuaError, + CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown), + [MemoryAccessFailure.ReadFailed] = new ExpectedMemoryFailure(CheatEngineFailureKind.MemoryReadFailed, + CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown), + [MemoryAccessFailure.PartialRead] = new ExpectedMemoryFailure(CheatEngineFailureKind.MemoryReadFailed, + CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown), + [MemoryAccessFailure.DestinationTooSmall] = new ExpectedMemoryFailure( + CheatEngineFailureKind.ResultLimitExceeded, CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown), + [MemoryAccessFailure.PointerWidthUnknown] = new ExpectedMemoryFailure(CheatEngineFailureKind.InvalidState, + CheatEngineHostEffect.NotStarted, CheatEngineHostEffect.NotStarted), + [MemoryAccessFailure.PointerValueExceedsTargetWidth] = new ExpectedMemoryFailure( + CheatEngineFailureKind.OperationRejected, CheatEngineHostEffect.Completed, CheatEngineHostEffect.NotStarted), + [MemoryAccessFailure.WriteFailed] = new ExpectedMemoryFailure(CheatEngineFailureKind.MemoryWriteFailed, + CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown), + [MemoryAccessFailure.InvalidResult] = new ExpectedMemoryFailure(CheatEngineFailureKind.InvalidHostResult, + CheatEngineHostEffect.Unknown, CheatEngineHostEffect.Unknown) + }; + + /// The public exception types of the consumed CheatEngine.SDK: every EngineException subclass, the + /// memory-scan exceptions and the Lua call exception. + private static Type[] SdkExceptionTypes => + [ + .. new[] + { + typeof(EngineException).Assembly, typeof(LuaException).Assembly, typeof(CheatEnginePlugin).Assembly, + typeof(LuaGlobalAttribute).Assembly + } + .SelectMany(static assembly => assembly.GetExportedTypes()) + .Where(static type => typeof(Exception).IsAssignableFrom(type) && !type.IsAbstract) + .OrderBy(static type => type.FullName, StringComparer.Ordinal) + ]; + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryPublicSdkExceptionTypeMapsToAKnownFailureKind() + { + Type[] exceptionTypes = SdkExceptionTypes; + List unmapped = []; + foreach (Type exceptionType in exceptionTypes) + { + Exception instance = (Exception) RuntimeHelpers.GetUninitializedObject(exceptionType); + if (CoreFailureFactory.GetKind(instance) == CheatEngineFailureKind.Unknown) + { + unmapped.Add(exceptionType.FullName!); + } + } + + Assert.True(exceptionTypes.Length >= 13, "The SDK exception inventory unexpectedly shrank."); + Assert.Contains(typeof(MemoryScanException), exceptionTypes); + Assert.Contains(typeof(MemoryScanStateException), exceptionTypes); + Assert.Contains(typeof(EngineTargetIdentityException), exceptionTypes); + Assert.True(unmapped.Count == 0, + "These SDK exception types map to CheatEngineFailureKind.Unknown: " + string.Join(", ", unmapped)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryEngineExceptionSubclassIsClassifiedByItsOwnFailureCategory() + { + Type[] engineExceptions = + [.. SdkExceptionTypes.Where(static type => typeof(EngineException).IsAssignableFrom(type))]; + List mismatched = []; + foreach (Type exceptionType in engineExceptions) + { + EngineException instance = (EngineException) RuntimeHelpers.GetUninitializedObject(exceptionType); + if (CoreFailureFactory.GetKind(instance) != CoreFailureFactory.FromEngineFailureKind(instance.Kind)) + { + mismatched.Add(exceptionType.FullName!); + } + } + + Assert.True(engineExceptions.Length >= 10, "The SDK EngineException inventory unexpectedly shrank."); + Assert.True(mismatched.Count == 0, + "These EngineException types are not classified by EngineException.Kind: " + string.Join(", ", mismatched)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryEngineFailureKindMapsToAKnownFailureKind() + { + MappingTotality.AssertTotal( + static kind => CoreFailureFactory.FromEngineFailureKind(kind) != CheatEngineFailureKind.Unknown, + static kind => CoreFailureFactory.FromEngineFailureKind(kind) == CheatEngineFailureKind.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMemoryScanFailureKindMapsToAKnownFailureKind() + { + MappingTotality.AssertTotal( + static kind => CoreFailureFactory.FromMemoryScanFailureKind(kind) != CheatEngineFailureKind.Unknown, + static kind => CoreFailureFactory.FromMemoryScanFailureKind(kind) == CheatEngineFailureKind.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryLuaAdmissionStatusIsClassifiedAndOnlyAdmittedSucceeds() + { + MappingTotality.AssertTotal(IsClassifiedAdmission, static status => + !LuaAdmission.TryClassify(status, "Lua.Contract", out CheatEngineFailure failure) && + failure.Kind == CheatEngineFailureKind.IndeterminateHostResult && + failure.HostEffect == CheatEngineHostEffect.NotStarted); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryEngineEffectStateMapsToItsHostEffect() + { + MappingTotality.AssertTotal( + static state => state == EngineEffectState.Unknown + ? HostEffectMapping.FromSdk(state) == CheatEngineHostEffect.Unknown + : HostEffectMapping.FromSdk(state) != CheatEngineHostEffect.Unknown, + static state => HostEffectMapping.FromSdk(state) == CheatEngineHostEffect.Unknown); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryInspectionStatusMapsToAKnownFailureKind() + { + List unmapped = []; + foreach (InspectionStatus status in Enum.GetValues()) + { + bool succeeded = InspectionClient.TryMap(status, "Inspection.Contract", out CheatEngineFailure failure); + if (status == InspectionStatus.Success) + { + Assert.True(succeeded); + continue; + } + + if (succeeded || failure.Kind == CheatEngineFailureKind.Unknown) + { + unmapped.Add(status.ToString()); + } + } + + Assert.True(unmapped.Count == 0, "These InspectionStatus values are not mapped: " + string.Join(", ", unmapped)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void AnUnavailableInspectionGlobalIsNotStartedAndAnUnknownStatusFailsClosed() + { + Assert.False(InspectionClient.TryMap(InspectionStatus.GlobalUnavailable, "Inspection.Contract", + out CheatEngineFailure unavailable)); + Assert.False(InspectionClient.TryMap((InspectionStatus) 99, "Inspection.Contract", + out CheatEngineFailure unrecognized)); + Assert.False(InspectionClient.TryMap(InspectionStatus.NotFound, "Inspection.Contract", + out CheatEngineFailure absent)); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, unavailable.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, unavailable.HostEffect); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, unrecognized.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, unrecognized.HostEffect); + Assert.Equal(CheatEngineFailureKind.NotFound, absent.Kind); + Assert.Equal(CheatEngineHostEffect.Unknown, absent.HostEffect); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void EveryMemoryAccessFailureMapsToItsClientKindAndHostEffect() + { + MappingTotality.AssertTotal( + static access => ExpectedMemoryFailures.TryGetValue(access, out ExpectedMemoryFailure expected) && + MemoryAccessFailureMapping.ToFailureKind(access) == expected.Kind && + MemoryAccessFailureMapping.ToHostEffect(access, false) == expected.ReadEffect && + MemoryAccessFailureMapping.ToHostEffect(access, true) == expected.WriteEffect, + static access => + MemoryAccessFailureMapping.ToFailureKind(access) == CheatEngineFailureKind.IndeterminateHostResult && + MemoryAccessFailureMapping.ToHostEffect(access, false) == CheatEngineHostEffect.Unknown && + MemoryAccessFailureMapping.ToHostEffect(access, true) == CheatEngineHostEffect.Unknown); + } + + /// + /// End to end through : every SDK failure of a byte read and a byte write reaches the + /// caller with its mapped kind and effect, and none of them, included, + /// is ever a success. + /// + [Fact] + [Trait("Qualification", "Q20")] + public void EveryMemoryAccessFailureReachesTheCallerWithItsMappedKindAndNeverSucceeds() + { + CoreLifetime lifetime = InertCoreLifetime.Create(); + foreach (MemoryAccessFailure access in Enum.GetValues()) + { + RefusingPort port = new(access); + MemoryClient client = new(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, port); + ExpectedMemoryFailure expected = ExpectedMemoryFailures[access]; + + Assert.False(client.TryReadBytes(new MemoryBytesReadRequest(0x1000, 4), out ImmutableArray bytes, + out CheatEngineFailure readFailure, TestContext.Current.CancellationToken)); + Assert.False(client.TryWriteBytes(new MemoryBytesWriteRequest(0x1000, [1]), + out CheatEngineFailure writeFailure, TestContext.Current.CancellationToken)); + MemoryBytesReadOutcome detailed = client.ReadBytesDetailed(new MemoryBytesReadRequest(0x1000, 4), + TestContext.Current.CancellationToken); + + Assert.True(bytes.IsEmpty); + Assert.False(detailed.IsSuccess); + Assert.Equal(expected.Kind, detailed.Failure!.Value.Kind); + // RefusingPort confirms half of the requested bytes on PartialRead only. + Assert.Equal(access == MemoryAccessFailure.PartialRead ? 2 : 0, detailed.ConfirmedLength); + Assert.Equal((expected.Kind, expected.ReadEffect, "Memory.ReadBytes"), + (readFailure.Kind, readFailure.HostEffect, readFailure.Operation)); + Assert.Equal((expected.Kind, expected.WriteEffect, "Memory.WriteBytes"), + (writeFailure.Kind, writeFailure.HostEffect, writeFailure.Operation)); + } + } + + /// + /// Admitted is the only success; every refusal is NotStarted and one of the four admission kinds, never a + /// rejection. + /// + private static bool IsClassifiedAdmission(LuaAdmissionStatus status) + { + bool admitted = LuaAdmission.TryClassify(status, "Lua.Contract", out CheatEngineFailure failure); + if (status == LuaAdmissionStatus.Admitted) + { + return admitted && failure == default; + } + + return !admitted && + failure.Kind is CheatEngineFailureKind.ActivationExpired or CheatEngineFailureKind.InvalidState + or CheatEngineFailureKind.RuntimeChanged or CheatEngineFailureKind.IndeterminateHostResult && + failure.HostEffect == CheatEngineHostEffect.NotStarted && + failure.Operation == "Lua.Contract"; + } + + /// Reports every access as refused with one SDK failure, as SdkMemoryCodecContextPort passes it on. + private sealed class RefusingPort(MemoryAccessFailure failure) : TargetObservationDouble, IMemoryCodecContextPort + { + public bool TryReadBytes(Address address, Span destination, out int written, + out MemoryAccessFailure hostFailure) + { + written = failure == MemoryAccessFailure.PartialRead ? destination.Length / 2 : 0; + hostFailure = failure; + return false; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source, out MemoryAccessFailure hostFailure) + { + hostFailure = failure; + return false; + } + } + + private readonly record struct ExpectedMemoryFailure( + CheatEngineFailureKind Kind, + CheatEngineHostEffect ReadEffect, + CheatEngineHostEffect WriteEffect); +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/AobScanDoubles.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/AobScanDoubles.cs new file mode 100644 index 0000000..740b8a9 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/AobScanDoubles.cs @@ -0,0 +1,355 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// Builds the copied SDK AOB outcomes and target observations that the AOB port doubles return. +internal static class AobHosts +{ + /// Gets the observation of a file opened as a process: no incarnation, the sentinel PID. + internal static TargetSelectionFacts FileAsProcess => new( + TargetSelectionObservationStatus.CurrentTargetFileAsProcess, TargetBackend.FileAsProcess, -1, null); + + /// Gets the observation of a CEServer target: a PID without a local incarnation. + internal static TargetSelectionFacts Remote => new( + TargetSelectionObservationStatus.CurrentTargetRemoteBackend, TargetBackend.CEServer, 42, null); + + /// Returns a qualified local selection (PID 42 by default). + internal static TargetSelectionFacts Local(int processId = 42, long startedAtUtcTicks = 1_000) + { + return new TargetSelectionFacts(TargetSelectionObservationStatus.CurrentTargetQualified, + TargetBackend.LocalProcess, processId, TargetObservations.Incarnation(processId, startedAtUtcTicks)); + } + + /// + /// Returns a bounded result as the SDK reports it after a session was created and released: both owners + /// Released, no stop required, and a host scan time when the scan completed. A + /// result carries the factory's + /// instead of a successful creation. + /// + internal static AobBoundedHostResult Bounded(AobBoundedScanOutcomeKind kind, bool scanCompleted = true) + { + return new AobBoundedHostResult + { + Kind = kind, + CreationStatus = kind == AobBoundedScanOutcomeKind.SessionCreationFailed + ? MemoryScanCreationStatus.NoScannerResult + : MemoryScanCreationStatus.Success, + LuaStatus = kind == AobBoundedScanOutcomeKind.ScanFailed ? LuaStatus.RuntimeError : LuaStatus.Ok, + HostScanElapsed = scanCompleted ? TimeSpan.FromMilliseconds(5) : TimeSpan.Zero, + FoundListRelease = TargetReleaseStatus.Released, + MemScanRelease = TargetReleaseStatus.Released, + ReleaseTermination = MemoryScanTerminationStatus.NotRequired + }; + } + + /// Returns a global outcome whose target observations both denote the same local incarnation. + internal static AobHostOutcome Outcome(AobScanOutcomeKind kind, int resultCount = 0) + { + return Outcome(kind, Local(), Local(), resultCount); + } + + /// Returns a global outcome with explicit target observations. + internal static AobHostOutcome Outcome(AobScanOutcomeKind kind, TargetSelectionFacts before, + TargetSelectionFacts after, int resultCount = 0) + { + LuaStatus luaStatus = kind == AobScanOutcomeKind.ProtectedLuaFailure ? LuaStatus.RuntimeError : LuaStatus.Ok; + return new AobHostOutcome(kind, luaStatus, resultCount, before, after); + } +} + +/// +/// A configurable . By default the global route hands out its list with the outcome the SDK +/// reports for that list (Matches, or NoMatches for an empty list), and reports InvalidResult +/// without a list; its before and after target observations are when it is set, and a +/// qualified local target otherwise, so an unqualified selection never yields a qualified global context. The +/// selection observation is unqualified unless is set, so a scoped request takes the global +/// route with managed post-filters. The bounded route copies into the destination like the +/// SDK (which drops rows by their start only), unless says what the SDK reports instead. +/// +internal sealed class FakeAobScanPort(RecordingAobMatchList? matchList = null) : IAobScanPort +{ + internal ModuleInfo[] Modules + { + get; + init; + } = []; + + internal int? ReportedModuleCount + { + get; + init; + } + + /// Gets the outcome to report instead of the one derived from the list. + internal AobHostOutcome? Outcome + { + get; + init; + } + + internal Action? OnScan + { + get; + init; + } + + internal Action? OnEnumerateModules + { + get; + init; + } + + internal int EnumerationCalls + { + get; + private set; + } + + internal int ScanCalls + { + get; + private set; + } + + internal int? EnumerationCallsWhenScanStarted + { + get; + private set; + } + + internal AobScanOptions? LastOptions + { + get; + private set; + } + + /// Gets the selection that reports; unqualified by default. + internal TargetSelectionFacts Selection + { + get; + init; + } + + /// Gets the in-bounds rows the bounded route returns, in Cheat Engine's order. + internal Address[] BoundedRows + { + get; + init; + } = []; + + /// Gets the result the bounded route reports instead of the one derived from . + internal AobBoundedHostResult? BoundedResult + { + get; + init; + } + + internal Action? OnObserveSelection + { + get; + init; + } + + internal Action? OnScanWithinBounds + { + get; + init; + } + + internal int SelectionCalls + { + get; + private set; + } + + internal int BoundedCalls + { + get; + private set; + } + + internal AobScanBounds? LastBounds + { + get; + private set; + } + + internal int? LastDestinationLength + { + get; + private set; + } + + internal CancellationToken? LastBoundedToken + { + get; + private set; + } + + public AobBoundedHostResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + BoundedCalls++; + LastOptions = options; + LastBounds = bounds; + LastDestinationLength = destination.Length; + LastBoundedToken = cancellationToken; + OnScanWithinBounds?.Invoke(); + if (BoundedResult is { } configured) + { + BoundedRows.AsSpan(0, Math.Min(Math.Min(configured.Written, BoundedRows.Length), destination.Length)) + .CopyTo(destination); + return configured; + } + + int written = Math.Min(BoundedRows.Length, destination.Length); + BoundedRows.AsSpan(0, written).CopyTo(destination); + AobBoundedHostResult completed = + AobHosts.Bounded(written > 0 ? AobBoundedScanOutcomeKind.Matches : AobBoundedScanOutcomeKind.NoMatches); + return completed with + { + HostResultCount = (ulong) BoundedRows.Length, + Written = written, + RowsRead = (ulong) written, + UnreadHostRows = (ulong) (BoundedRows.Length - written), + IsMaterializationLimitReached = written == destination.Length && BoundedRows.Length > written, + CopyElapsed = TimeSpan.FromMilliseconds(1) + }; + } + + public TargetSelectionFacts ObserveSelection() + { + SelectionCalls++; + OnObserveSelection?.Invoke(); + return Selection; + } + + public AobHostOutcome TryScan(string pattern, AobScanOptions options, out IAobMatchList? matches) + { + ScanCalls++; + LastOptions = options; + EnumerationCallsWhenScanStarted ??= EnumerationCalls; + OnScan?.Invoke(); + matches = matchList; + TargetSelectionFacts context = Selection == default ? AobHosts.Local() : Selection; + return Outcome ?? (matchList is null + ? AobHosts.Outcome(AobScanOutcomeKind.InvalidResult, context, context) + : AobHosts.Outcome(matchList.Count == 0 ? AobScanOutcomeKind.NoMatches : AobScanOutcomeKind.Matches, + context, context, matchList.Count)); + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + EnumerationCalls++; + OnEnumerateModules?.Invoke(); + Array.Copy(Modules, destination, Math.Min(Modules.Length, destination.Length)); + written = ReportedModuleCount ?? Modules.Length; + return InspectionStatus.Success; + } +} + +/// An in-memory AOB result list that records its reads and its single release. +internal sealed class RecordingAobMatchList(IReadOnlyList items) : IAobMatchList +{ + internal int Count => items.Count; + + internal Action? OnTryGetItem + { + get; + init; + } + + /// Gets the SDK status the release reports; by default. + internal TargetReleaseStatus ReleaseStatus + { + get; + init; + } = TargetReleaseStatus.Released; + + /// Gets an exception the release throws, breaking the port contract. + internal Exception? ReleaseFailure + { + get; + init; + } + + internal int? ReportedCount + { + get; + init; + } + + internal bool CountAvailable + { + get; + init; + } = true; + + internal int CountCalls + { + get; + private set; + } + + internal int ItemCalls + { + get; + private set; + } + + internal int ReleaseCount + { + get; + private set; + } + + internal int? ReleaseThreadId + { + get; + private set; + } + + internal bool IsReleased => ReleaseCount > 0; + + public bool TryGetCount(out int count) + { + CountCalls++; + count = ReportedCount ?? items.Count; + return CountAvailable; + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + ItemCalls++; + OnTryGetItem?.Invoke(index); + if ((uint) index >= items.Count) + { + value = null; + return false; + } + + value = items[index]; + return true; + } + + public TargetReleaseStatus Release() + { + ReleaseCount++; + ReleaseThreadId = Environment.CurrentManagedThreadId; + if (ReleaseFailure is not null) + { + throw ReleaseFailure; + } + + return ReleaseStatus; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/DedicatedThreadInvoker.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/DedicatedThreadInvoker.cs new file mode 100644 index 0000000..b538fbd --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/DedicatedThreadInvoker.cs @@ -0,0 +1,82 @@ +using System.Collections.Concurrent; + +using CheatEngine.Client.Core.Dispatching; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// +/// Runs every callback on one dedicated thread, as Cheat Engine's main thread, and waits for it; a callback that is +/// already on that thread runs inline. +/// +internal sealed class DedicatedThreadInvoker : IMainThreadInvoker, IDisposable +{ + private readonly Thread _thread; + private readonly BlockingCollection _work = []; + + internal DedicatedThreadInvoker() + { + _thread = new Thread(Pump) + { + IsBackground = true, + Name = "Cheat Engine main thread (test)" + }; + _thread.Start(); + } + + /// Gets the managed identifier of the dedicated thread. + internal int ThreadId => _thread.ManagedThreadId; + + public void Dispose() + { + _work.CompleteAdding(); + _thread.Join(); + _work.Dispose(); + } + + public Exception? Invoke(Action callback) + { + return Invoke(() => + { + callback(); + return true; + }).Exception; + } + + public MainThreadInvocationResult Invoke(Func callback) + { + if (Environment.CurrentManagedThreadId == _thread.ManagedThreadId) + { + return Execute(callback); + } + + MainThreadInvocationResult result = default; + using ManualResetEventSlim done = new(); + _work.Add(() => + { + result = Execute(callback); + done.Set(); + }); + done.Wait(); + return result; + } + + private static MainThreadInvocationResult Execute(Func callback) + { + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + + private void Pump() + { + foreach (Action work in _work.GetConsumingEnumerable()) + { + work(); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeAllocationPort.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeAllocationPort.cs new file mode 100644 index 0000000..1c4f043 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeAllocationPort.cs @@ -0,0 +1,146 @@ +using CheatEngine.Client.Core.Domains.Allocations; +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// A scripted allocator: returns , and when it publishes an owner. +internal sealed class FakeAllocationPort : IAllocationPort +{ + /// The address of a successful scripted allocation. + internal static readonly Address AllocatedAddress = new(0x7FF0_0000_1000); + + internal AllocationAttempt Attempt + { + get; + set; + } = new(TargetMemoryOperationOutcomeKind.Succeeded, EngineEffectState.Applied, AllocatedAddress, null); + + /// Whether the allocator publishes , as CheatEngine.SDK does only with an owner. + internal bool PublishesOwner + { + get; + set; + } = true; + + internal Exception? Fault + { + get; + set; + } + + internal Action? DuringAllocate + { + get; + set; + } + + /// The owner the next allocation publishes; replace it to publish another allocation. + internal FakeAllocatedRegion Region + { + get; + set; + } = new(); + + internal int Allocations + { + get; + private set; + } + + internal TargetAllocationRequest? LastRequest + { + get; + private set; + } + + /// Scripts an allocation that published no owner. + internal void Refuse(TargetMemoryOperationOutcomeKind kind, EngineEffectState effect, Address address = default, + TargetReleaseStatus? compensation = null) + { + PublishesOwner = false; + Attempt = new AllocationAttempt(kind, effect, address, compensation); + } + + public AllocationAttempt TryAllocate(in TargetAllocationRequest request, out IAllocatedRegionHandle? region) + { + Allocations++; + LastRequest = request; + DuringAllocate?.Invoke(); + if (Fault is { } fault) + { + throw fault; + } + + region = PublishesOwner ? Region : null; + return Attempt; + } +} + +/// +/// Emulates the CheatEngine.SDK 2.0.0 AllocatedRegion release the Client relies on: the one attempt consumes +/// the owner, a refusal makes no Cheat Engine call, and a later call reports the first attempt again without any call. +/// +internal sealed class FakeAllocatedRegion : IAllocatedRegionHandle +{ + private TargetReleaseStatus? _consumed; + + /// The incarnation the SDK bound the allocation to, the default selected target's unless replaced. + public TargetProcessIncarnation TargetIncarnation + { + get; + set; + } = FakeSelectedTarget.FirstIncarnation; + + /// The status of the one release attempt. + internal TargetReleaseStatus ReleaseStatus + { + get; + set; + } = TargetReleaseStatus.Released; + + internal Exception? ReleaseFault + { + get; + set; + } + + /// Gets the number of release requests the Client made. + internal int ReleaseCalls + { + get; + private set; + } + + /// Gets the number of deAlloc calls: only a release that reached Cheat Engine makes one. + internal int Deallocations + { + get; + private set; + } + + public TargetReleaseStatus Release() + { + ReleaseCalls++; + if (_consumed is { } consumed) + { + return consumed; + } + + if (ReleaseFault is { } fault) + { + _consumed = TargetReleaseStatus.UnconfirmedAfterInvocation; + throw fault; + } + + if (ReleaseStatus is TargetReleaseStatus.Released or TargetReleaseStatus.UnconfirmedAfterInvocation) + { + Deallocations++; + } + + _consumed = ReleaseStatus; + return ReleaseStatus; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeRuntimeObservationPort.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeRuntimeObservationPort.cs new file mode 100644 index 0000000..03de4f8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeRuntimeObservationPort.cs @@ -0,0 +1,350 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// +/// A configurable Cheat Engine for the observation and selection ports that behaves like the CheatEngine.SDK 2.0.0 +/// operations: by default Cheat Engine 7.7.0.10621 x64 on Windows with an x64 local target (PID 42). +/// +/// +/// Unless or is set, the aggregate snapshot follows the +/// SDK's rules: a failed host read fails it, a selected or absent target is a snapshot, any other target status is +/// returned unchanged, and the capability list names each probed fact. The selection observation follows the target +/// unless is set: a local target is qualified when it has an +/// . SelectAndObserve selects the requested process unless +/// says what Cheat Engine selects instead. +/// +internal class FakeRuntimeObservationPort : TargetObservationDouble, IRuntimeObservationPort, IProcessSelectionPort +{ + /// Gets the host facts of Cheat Engine 7.7.0.10621 x64 on Windows. + internal static CheatEngineHostObservation DefaultHost => new(CheatEngineVersion.Ce77010621, + CheatEngineArchitecture.X64, true, CheatEngineOperatingSystem.Windows); + + /// Gets or sets the host facts. + internal CheatEngineHostObservation Host + { + get; + set; + } = DefaultHost; + + /// Gets or sets the status of the aggregate host observation. + internal LuaOperationStatus HostStatus + { + get; + set; + } = LuaOperationStatus.Success; + + /// Gets or sets the status of the file-version read. + internal LuaOperationStatus FileVersionStatus + { + get; + set; + } = LuaOperationStatus.Success; + + /// Gets or sets the status of the system-architecture read. + internal LuaOperationStatus SystemArchitectureStatus + { + get; + set; + } = LuaOperationStatus.Success; + + /// Gets or sets the status of the Cheat Engine bitness read. + internal LuaOperationStatus CheatEngineBitnessStatus + { + get; + set; + } = LuaOperationStatus.Success; + + /// Gets or sets the status of the operating-system read. + internal LuaOperationStatus OperatingSystemStatus + { + get; + set; + } = LuaOperationStatus.Success; + + /// Gets or sets the status of the aggregate snapshot; derived from the host and target when unset. + internal ProcessOperationStatus? RuntimeInfoStatus + { + get; + set; + } + + /// Gets or sets the SDK capability list of the snapshot; derived from the facts when unset. + internal RuntimeCapabilities? Capabilities + { + get; + set; + } + + /// Gets or sets an exception every member, target observations included, throws. + internal Exception? Fault + { + get; + set + { + field = value; + TargetFault = value; + } + } + + /// Gets the names of the host, snapshot, selection and attach operations in call order. + internal List Calls + { + get; + } = []; + + /// Gets or sets the incarnation of a local target; leaves it unqualified. + internal TargetProcessIncarnation? Incarnation + { + get; + set; + } + + /// Gets or sets the status of the selection observation; derived from the target when unset. + internal TargetSelectionObservationStatus? SelectionStatus + { + get; + set; + } + + /// Gets or sets the status of SelectAndObserve; derived from the resulting selection when unset. + internal ProcessOperationStatus? SelectStatus + { + get; + set; + } + + /// Gets or sets what Cheat Engine does when asked to select a PID; it selects that PID when unset. + internal Action? OnSelect + { + get; + set; + } + + /// Gets or sets an exception SelectAndObserve throws. + internal Exception? SelectFault + { + get; + set; + } + + /// Gets the PIDs SelectAndObserve was asked to select. + internal List SelectCalls + { + get; + } = []; + + public ProcessOperationStatus TryObserveRuntimeInfo(out RuntimeInfo? info) + { + Record(nameof(TryObserveRuntimeInfo)); + ProcessOperationStatus status = RuntimeInfoStatus ?? DeriveRuntimeInfoStatus(); + info = status.IsSuccess + ? new RuntimeInfo(Host, TargetStatus.IsSuccess ? Target : null, Capabilities ?? DeriveCapabilities()) + : null; + return status; + } + + public LuaOperationStatus ObserveHost(out CheatEngineHostObservation host) + { + Record(nameof(ObserveHost)); + host = HostStatus.IsSuccess ? Host : default; + return HostStatus; + } + + public LuaOperationStatus TryGetCheatEngineFileVersion(out CheatEngineVersion version) + { + Record(nameof(TryGetCheatEngineFileVersion)); + version = FileVersionStatus.IsSuccess ? Host.FileVersion.GetValueOrDefault() : default; + return FileVersionStatus; + } + + public LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchitecture architecture) + { + Record(nameof(TryGetSystemArchitecture)); + architecture = SystemArchitectureStatus.IsSuccess ? Host.SystemArchitecture : CheatEngineArchitecture.Unknown; + return SystemArchitectureStatus; + } + + public LuaOperationStatus TryIsCheatEngine64Bit(out bool is64Bit) + { + Record(nameof(TryIsCheatEngine64Bit)); + is64Bit = CheatEngineBitnessStatus.IsSuccess && Host.CheatEngineIs64Bit == true; + return CheatEngineBitnessStatus; + } + + public LuaOperationStatus TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem) + { + Record(nameof(TryGetOperatingSystem)); + operatingSystem = OperatingSystemStatus.IsSuccess ? Host.OperatingSystem : CheatEngineOperatingSystem.Unknown; + return OperatingSystemStatus; + } + + public TargetSelectionFacts ObserveSelection() + { + Record(nameof(ObserveSelection)); + return CurrentSelection(); + } + + public TargetIdentityFacts ValidateSelection(TargetProcessIncarnation expected) + { + Record(nameof(ValidateSelection)); + TargetSelectionFacts observed = CurrentSelection(); + TargetIdentityCheckKind kind = observed is + { + Status: TargetSelectionObservationStatus.CurrentTargetQualified, Incarnation: { } current + } + ? current.ProcessId != expected.ProcessId + ? TargetIdentityCheckKind.TargetChanged + : current.StartedAtUtcTicks == expected.StartedAtUtcTicks + ? TargetIdentityCheckKind.Current + : TargetIdentityCheckKind.ProcessReused + : observed.Status switch + { + TargetSelectionObservationStatus.NoTargetSelected => TargetIdentityCheckKind.NoTargetSelected, + TargetSelectionObservationStatus.CurrentTargetUnqualified => + TargetIdentityCheckKind.CurrentTargetUnqualified, + TargetSelectionObservationStatus.GlobalUnavailable => TargetIdentityCheckKind.GlobalUnavailable, + TargetSelectionObservationStatus.LuaFailure => TargetIdentityCheckKind.LuaFailure, + TargetSelectionObservationStatus.CurrentTargetRemoteBackend => TargetIdentityCheckKind.RemoteBackend, + TargetSelectionObservationStatus.CurrentTargetFileAsProcess => TargetIdentityCheckKind.FileAsProcess, + TargetSelectionObservationStatus.CurrentTargetBackendUnknown => TargetIdentityCheckKind.BackendUnknown, + _ => TargetIdentityCheckKind.InvalidResult + }; + return new TargetIdentityFacts(kind, observed); + } + + public ProcessOperationStatus SelectAndObserve(TargetProcessId processId, + out CurrentProcessObservation observation) + { + Record(nameof(SelectAndObserve)); + SelectCalls.Add(processId.Value); + if (SelectFault is { } fault) + { + throw fault; + } + + if (OnSelect is { } select) + { + select(processId.Value); + } + else + { + TargetStatus = ProcessOperationStatus.Success; + Target = TargetObservations.WithProcessId(Target, processId.Value); + } + + ProcessOperationStatus status = SelectStatus ?? (TargetStatus.IsSuccess && Target.ProcessId == processId + ? ProcessOperationStatus.Success + : ProcessOperationStatus.SelectionNotConfirmed); + observation = status.IsSuccess ? new CurrentProcessObservation(processId, Target.Bitness) : default; + return status; + } + + /// Counts the calls of one host, snapshot, selection or attach operation. + internal int Count(string member) + { + return Calls.Count(call => call == member); + } + + private ProcessOperationStatus DeriveRuntimeInfoStatus() + { + if (!HostStatus.IsSuccess) + { + return HostStatus.Kind switch + { + LuaOperationStatusKind.LuaFailure => TargetObservations.LuaFailure, + LuaOperationStatusKind.GlobalUnavailable => ProcessOperationStatus.GlobalUnavailable, + _ => ProcessOperationStatus.InvalidResult + }; + } + + return TargetStatus.Kind is ProcessOperationStatusKind.Success or ProcessOperationStatusKind.TargetNotAttached + or ProcessOperationStatusKind.GlobalUnavailable + ? ProcessOperationStatus.Success + : TargetStatus; + } + + private RuntimeCapabilities DeriveCapabilities() + { + List entries = + [ + Available(RuntimeCapabilityId.CheatEngineVersion), + Available(RuntimeCapabilityId.SystemArchitecture), + Available(RuntimeCapabilityId.CheatEngineBitness), + Available(RuntimeCapabilityId.OperatingSystem) + ]; + switch (TargetStatus.Kind) + { + case ProcessOperationStatusKind.Success: + entries.AddRange( + [ + Available(RuntimeCapabilityId.CurrentProcess), Available(RuntimeCapabilityId.TargetBackend), + Available(RuntimeCapabilityId.TargetArchitecture), Available(RuntimeCapabilityId.TargetAndroid), + Available(RuntimeCapabilityId.TargetAbi), Available(RuntimeCapabilityId.ConfiguredPointerSize) + ]); + break; + case ProcessOperationStatusKind.TargetNotAttached: + entries.Add(Available(RuntimeCapabilityId.CurrentProcess)); + break; + case ProcessOperationStatusKind.GlobalUnavailable: + entries.Add(new RuntimeCapabilityAvailability(RuntimeCapabilityId.CurrentProcess, + RuntimeCapabilityAvailabilityState.Unavailable, RuntimeCapabilityContract.Unknown)); + break; + } + + return RuntimeCapabilities.Create([.. entries]); + } + + private TargetSelectionFacts CurrentSelection() + { + TargetSelectionObservationStatus status = SelectionStatus ?? TargetStatus.Kind switch + { + ProcessOperationStatusKind.TargetNotAttached => TargetSelectionObservationStatus.NoTargetSelected, + ProcessOperationStatusKind.FileAsProcessTarget => TargetSelectionObservationStatus.CurrentTargetFileAsProcess, + _ => Target.Backend switch + { + TargetBackend.LocalProcess => Incarnation is null + ? TargetSelectionObservationStatus.CurrentTargetUnqualified + : TargetSelectionObservationStatus.CurrentTargetQualified, + TargetBackend.CEServer => TargetSelectionObservationStatus.CurrentTargetRemoteBackend, + _ => TargetSelectionObservationStatus.CurrentTargetBackendUnknown + } + }; + TargetBackend backend = status switch + { + TargetSelectionObservationStatus.CurrentTargetQualified or + TargetSelectionObservationStatus.CurrentTargetUnqualified => TargetBackend.LocalProcess, + TargetSelectionObservationStatus.CurrentTargetRemoteBackend => TargetBackend.CEServer, + TargetSelectionObservationStatus.CurrentTargetFileAsProcess => TargetBackend.FileAsProcess, + _ => TargetBackend.Unknown + }; + int? processId = status is TargetSelectionObservationStatus.CurrentTargetQualified + or TargetSelectionObservationStatus.CurrentTargetUnqualified + or TargetSelectionObservationStatus.CurrentTargetRemoteBackend + or TargetSelectionObservationStatus.CurrentTargetBackendUnknown + ? Target.ProcessId.Value + : null; + return new TargetSelectionFacts(status, backend, processId, + status == TargetSelectionObservationStatus.CurrentTargetQualified ? Incarnation : null); + } + + private static RuntimeCapabilityAvailability Available(RuntimeCapabilityId capability) + { + return new RuntimeCapabilityAvailability(capability, RuntimeCapabilityAvailabilityState.Available, + RuntimeCapabilityContract.Unknown); + } + + private void Record(string member) + { + Calls.Add(member); + if (Fault is { } fault) + { + throw fault; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeSelectedTarget.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeSelectedTarget.cs new file mode 100644 index 0000000..6d40cf2 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeSelectedTarget.cs @@ -0,0 +1,61 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// +/// Cheat Engine's selected local process for a real , the selection binder of target-bound +/// leases: by default the x64 process 42 in its . changes the +/// selection as Cheat Engine's own window does, without any Client call. +/// +internal sealed class FakeSelectedTarget : FakeRuntimeObservationPort, IProcessHost +{ + internal FakeSelectedTarget() + { + Select(FirstIncarnation); + } + + /// Gets the incarnation of process 42 that the scripted SDK owners are bound to by default. + internal static TargetProcessIncarnation FirstIncarnation => + TargetObservations.Incarnation(42, 638_000_000_000_000_000); + + /// Gets an incarnation of another process, 43. + internal static TargetProcessIncarnation OtherProcessIncarnation => + TargetObservations.Incarnation(43, 638_000_000_100_000_000); + + /// Creates the process client of an activation over a selected target (a new one when omitted). + /// The activation dispatcher. + /// The selected target. + /// The process client, which binds new target-bound owners to the selection. + internal static ProcessClient CreateProcessClient(SdkMainThreadDispatcher dispatcher, + FakeSelectedTarget? target = null) + { + target ??= new FakeSelectedTarget(); + return new ProcessClient(dispatcher, target, target, target, dispatcher.Lifetime); + } + + /// Selects a local process incarnation, as Cheat Engine's own window does. + /// The newly selected incarnation. + internal void Select(TargetProcessIncarnation incarnation) + { + Target = TargetObservations.WithProcessId(Target, incarnation.ProcessId); + Incarnation = incarnation; + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + process = default; + return false; + } + + public IReadOnlyList GetLocalProcesses() + { + return []; + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + return []; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeValueScanPort.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeValueScanPort.cs new file mode 100644 index 0000000..7acbd4a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/FakeValueScanPort.cs @@ -0,0 +1,599 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Core.Domains.ValueScanning; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// A scripted scan-session factory: returns when is a success. +internal sealed class FakeValueScanPort : IValueScanPort +{ + internal MemoryScanCreationStatus Status + { + get; + set; + } = MemoryScanCreationStatus.Success; + + internal Exception? Fault + { + get; + set; + } + + internal Action? DuringCreate + { + get; + set; + } + + /// The session the next creation publishes; replace it to publish another session. + internal FakeValueScanSessionHandle Session + { + get; + set; + } = new(); + + internal int Creations + { + get; + private set; + } + + /// + /// Gets or sets whether a failed creation still publishes , breaking the SDK contract. + /// + internal bool PublishesSessionOnFailure + { + get; + set; + } + + public MemoryScanCreationStatus TryCreate(out IValueScanSessionHandle? session) + { + Creations++; + DuringCreate?.Invoke(); + if (Fault is { } fault) + { + throw fault; + } + + session = Status == MemoryScanCreationStatus.Success || PublishesSessionOnFailure ? Session : null; + return Status; + } +} + +/// +/// Emulates the CheatEngine.SDK 2.0.0 MemoryScanSession state machine that the Client relies on: state checks +/// before the Cheat Engine calls, the conservative invalidation around them, cancellation milestones, the refusal of a +/// call made while another member is inside Cheat Engine, the one deferred release, and the one cooperative stop +/// (terminateScan, then waitTillDone(5000)) that the release makes when a scan may still be running. +/// +internal sealed class FakeValueScanSessionHandle : IValueScanSessionHandle +{ + private bool _active; + private ValueScanReleaseStatuses? _finalRelease; + private bool _releaseDeferred; + + // As in the SDK: true from immediately before a firstScan or nextScan call until a wait returns, a stop is + // confirmed, or a reset succeeds. + private bool _scanMayBeRunning; + + internal List Calls + { + get; + } = []; + + /// The incarnation the SDK bound the session to, the default selected target's unless replaced. + public TargetProcessIncarnation TargetIncarnation + { + get; + set; + } = FakeSelectedTarget.FirstIncarnation; + + internal List Results + { + get; + } = []; + + /// A count that overrides the size of . + internal ulong? ReportedCount + { + get; + set; + } + + /// A context refusal (changed runtime or target) thrown before any Cheat Engine call. + internal MemoryScanException? ContextFault + { + get; + set; + } + + internal Exception? StartFault + { + get; + set; + } + + internal Exception? WaitFault + { + get; + set; + } + + internal Exception? ResetFault + { + get; + set; + } + + internal Exception? CountFault + { + get; + set; + } + + internal Exception? ReleaseFault + { + get; + set; + } + + internal MemoryScanMaterializationStatus? CopyStatus + { + get; + set; + } + + /// Runs while the scan call is inside Cheat Engine, after its own cancellation check. + internal Action? DuringStart + { + get; + set; + } + + /// Runs before the start's own cancellation check, as a token cancelled in a race would. + internal Action? BeforeStartCheck + { + get; + set; + } + + /// Runs while the wait is inside Cheat Engine; Cheat Engine runs queued main-thread work there. + internal Action? DuringWait + { + get; + set; + } + + internal string? HostErrorText + { + get; + set; + } + + internal bool HostErrorTextTruncated + { + get; + set; + } + + /// The SDK status of the release of the found list and of the scanner; both are released by default. + internal (TargetReleaseStatus FoundList, TargetReleaseStatus MemScan) OwnerReleases + { + get; + set; + } = (TargetReleaseStatus.Released, TargetReleaseStatus.Released); + + /// How the release's cooperative stop of a scan that may still run ends; confirmed by default. + internal MemoryScanTerminationStatus StopStatus + { + get; + set; + } = MemoryScanTerminationStatus.Confirmed; + + /// Gets the number of cooperative stops the release requested from Cheat Engine. + internal int StopRequests + { + get; + private set; + } + + /// Runs when the Client asks for the release, before the SDK decides anything. + internal Action? OnRelease + { + get; + set; + } + + /// Gets the number of releases the SDK completed, each consuming both owners. + internal int Destroys + { + get; + private set; + } + + internal FirstScanRequest? LastFirstScan + { + get; + private set; + } + + internal NextScanRequest? LastNextScan + { + get; + private set; + } + + public MemoryScanState State + { + get; + set; + } = MemoryScanState.New; + + public MemoryScanInvalidationReason InvalidationReason + { + get; + set; + } + + public MemoryScanCancellationMilestone LastCancellationMilestone + { + get; + private set; + } + + public ulong ReadResultCount() + { + Calls.Add("ResultCount"); + RequireState(MemoryScanState.ResultsReady); + Begin(); + try + { + EnsureContext(); + if (CountFault is { } fault) + { + throw fault; + } + + return ReportedCount ?? (ulong) Results.Count; + } + finally + { + End(); + } + } + + public void StartFirstScan(in FirstScanRequest request, CancellationToken cancellationToken) + { + Calls.Add("StartFirstScan"); + Start(MemoryScanState.New, cancellationToken); + LastFirstScan = request; + } + + public void StartNextScan(in NextScanRequest request, CancellationToken cancellationToken) + { + Calls.Add("StartNextScan"); + Start(MemoryScanState.ResultsReady, cancellationToken); + LastNextScan = request; + } + + public void WaitForCompletion(CancellationToken cancellationToken) + { + Calls.Add("Wait"); + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireState(MemoryScanState.Scanning); + Begin(); + try + { + EnsureContext(); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + DuringWait?.Invoke(); + if (!_releaseDeferred) + { + if (WaitFault is { } fault) + { + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + throw fault; + } + + Complete(MemoryScanState.ResultsReady); + } + + // waitTillDone returned: the scan no longer runs, whether results or a deferred release follow. + _scanMayBeRunning = false; + + ObserveCancellation(cancellationToken); + } + finally + { + End(); + } + + ThrowIfReleasedDuringCall(); + } + + public void Reset(CancellationToken cancellationToken) + { + Calls.Add("Reset"); + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + ThrowIfDisposed(); + if (State == MemoryScanState.New) + { + return; + } + + if (State == MemoryScanState.Scanning) + { + throw ScanFaults.State(); + } + + Begin(); + try + { + EnsureContext(); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + if (ResetFault is { } fault) + { + throw fault; + } + + Complete(MemoryScanState.New); + _scanMayBeRunning = false; + ObserveCancellation(cancellationToken); + } + finally + { + End(); + } + } + + public MemoryScanMaterializationStatus TryCopyResultsPage(int firstResultIndex, + Span destination, out ulong totalCount, out int written, CancellationToken cancellationToken) + { + Calls.Add("CopyPage"); + totalCount = 0; + written = 0; + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireState(MemoryScanState.ResultsReady); + Begin(); + try + { + if (CopyStatus is { } scripted) + { + return scripted; + } + + if (cancellationToken.IsCancellationRequested) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + totalCount = ReportedCount ?? (ulong) Results.Count; + if (totalCount == 0) + { + return MemoryScanMaterializationStatus.NoResults; + } + + if ((ulong) firstResultIndex >= totalCount) + { + return MemoryScanMaterializationStatus.PageStartOutOfRange; + } + + int length = (int) Math.Min((ulong) destination.Length, totalCount - (ulong) firstResultIndex); + for (int index = 0; index < length; index++) + { + destination[index] = Results[firstResultIndex + index]; + } + + written = length; + return MemoryScanMaterializationStatus.Success; + } + finally + { + End(); + } + } + + public bool TryGetHostErrorText([NotNullWhen(true)] out string? text, out bool truncated) + { + ThrowIfDisposed(); + text = HostErrorText; + truncated = HostErrorTextTruncated; + return text is not null; + } + + public ValueScanReleaseStatuses Release() + { + OnRelease?.Invoke(); + if (ReleaseFault is { } fault) + { + throw fault; + } + + if (State == MemoryScanState.Disposed) + { + return _finalRelease ?? default; + } + + if (_active) + { + // Called from inside one of this session's Cheat Engine calls: deferred until that call returns. + _releaseDeferred = true; + return default; + } + + CompleteRelease(); + return _finalRelease ?? default; + } + + private void Start(MemoryScanState required, CancellationToken cancellationToken) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireState(required); + Begin(); + try + { + EnsureContext(); + BeforeStartCheck?.Invoke(); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + _scanMayBeRunning = true; + DuringStart?.Invoke(); + if (StartFault is { } fault) + { + throw fault; + } + + Complete(MemoryScanState.Scanning); + ObserveCancellation(cancellationToken); + } + finally + { + End(); + } + + ThrowIfReleasedDuringCall(); + } + + private void EnsureContext() + { + if (ContextFault is not { } fault) + { + return; + } + + Invalidate(fault.FailureKind == MemoryScanFailureKind.RuntimeInvalidated + ? MemoryScanInvalidationReason.RuntimeIdentityChanged + : MemoryScanInvalidationReason.TargetChanged); + throw fault; + } + + private void RequireState(MemoryScanState expected) + { + ThrowIfDisposed(); + if (_active || State != expected) + { + throw ScanFaults.State(); + } + } + + private void Begin() + { + if (_active) + { + throw ScanFaults.State(); + } + + _active = true; + } + + private void End() + { + _active = false; + if (!_releaseDeferred) + { + return; + } + + _releaseDeferred = false; + CompleteRelease(); + } + + private void CompleteRelease() + { + // A refused or detached release consumes both owners without any Cheat Engine call, so a scan that may run gets + // no stop request (NotInvoked); a release that reaches Cheat Engine first asks a running scan to stop, once. + bool consumedWithoutCleanup = OwnerReleases.FoundList is not (TargetReleaseStatus.Released + or TargetReleaseStatus.UnconfirmedAfterInvocation); + MemoryScanTerminationStatus termination = MemoryScanTerminationStatus.NotRequired; + if (_scanMayBeRunning && consumedWithoutCleanup) + { + termination = MemoryScanTerminationStatus.NotInvoked; + } + else if (_scanMayBeRunning) + { + StopRequests++; + termination = StopStatus; + } + + Destroys++; + State = MemoryScanState.Disposed; + _finalRelease = new ValueScanReleaseStatuses(OwnerReleases.FoundList, OwnerReleases.MemScan, termination); + } + + private void ThrowIfDisposed() + { + if (State == MemoryScanState.Disposed) + { + throw new ObjectDisposedException("MemoryScanSession", "The memory-scan session was disposed."); + } + } + + private void ThrowIfReleasedDuringCall() + { + if (State == MemoryScanState.Disposed) + { + throw new ObjectDisposedException("MemoryScanSession", + "The memory-scan session was released by a call made while it was inside a Cheat Engine call."); + } + } + + private void ThrowIfCancelledBeforeNativeCall(CancellationToken cancellationToken) + { + if (!cancellationToken.IsCancellationRequested) + { + return; + } + + LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; + throw new OperationCanceledException(cancellationToken); + } + + private void ObserveCancellation(CancellationToken cancellationToken) + { + LastCancellationMilestone = cancellationToken.IsCancellationRequested + ? MemoryScanCancellationMilestone.ObservedAfterNativeCall + : MemoryScanCancellationMilestone.None; + } + + private void Invalidate(MemoryScanInvalidationReason reason) + { + State = MemoryScanState.Invalidated; + InvalidationReason = reason; + } + + private void Complete(MemoryScanState state) + { + State = state; + InvalidationReason = MemoryScanInvalidationReason.None; + } +} + +/// Builds the memory-scan exceptions that CheatEngine.SDK constructs internally only. +internal static class ScanFaults +{ + /// A state refusal; the Client classifies it by its type alone. + internal static MemoryScanStateException State() + { + return (MemoryScanStateException) RuntimeHelpers.GetUninitializedObject(typeof(MemoryScanStateException)); + } + + /// A memory-scan failure of the given category. + internal static MemoryScanException Scan(MemoryScanFailureKind kind) + { + MemoryScanException exception = + (MemoryScanException) RuntimeHelpers.GetUninitializedObject(typeof(MemoryScanException)); + FieldInfo field = typeof(MemoryScanException).GetField("k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + "MemoryScanException.FailureKind is no longer an auto-property."); + field.SetValue(exception, kind); + return exception; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs index ca0b707..ef6a535 100644 --- a/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs @@ -4,9 +4,9 @@ namespace CheatEngine.Client.Core.Tests.TestSupport; internal static class InertCoreLifetime { - internal static CoreLifetime Create() + internal static CoreLifetime Create(ICoreDiagnostics? diagnostics = null) { - return new CoreLifetime(new AlwaysCurrentLifetimeContext()); + return new CoreLifetime(new AlwaysCurrentLifetimeContext(), diagnostics); } private sealed class AlwaysCurrentLifetimeContext : ICoreLifetimeContext diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/MappingTotality.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/MappingTotality.cs new file mode 100644 index 0000000..8f97fc7 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/MappingTotality.cs @@ -0,0 +1,46 @@ +using System.Globalization; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// Proves that a Client mapping over an SDK outcome enum is total and fails closed. +/// +/// Every value that the consumed CheatEngine.SDK defines must reach its dedicated Client value, and a value the SDK +/// could add later must reach the conservative fallback. A new value in a candidate SDK package therefore fails the +/// calling test instead of silently becoming a fallback in production. +/// +internal static class MappingTotality +{ + /// Asserts that is mapped totally and that an unknown value fails closed. + /// The SDK enum the mapping consumes. + /// Whether a defined value reaches its dedicated Client value. + /// Whether a value outside reaches the conservative fallback. + internal static void AssertTotal(Func isMapped, Func failsClosed) + where TEnum : struct, Enum + { + ArgumentNullException.ThrowIfNull(isMapped); + ArgumentNullException.ThrowIfNull(failsClosed); + + TEnum[] values = Enum.GetValues(); + string[] unmapped = [.. values.Where(value => !isMapped(value)).Select(static value => value.ToString())]; + TEnum undefined = Undefined(); + + Assert.NotEmpty(values); + Assert.True(unmapped.Length == 0, + $"These {typeof(TEnum).FullName} values are not mapped: {string.Join(", ", unmapped)}"); + Assert.False(Enum.IsDefined(undefined)); + Assert.True(failsClosed(undefined), + $"The undefined {typeof(TEnum).FullName} value {undefined} does not fail closed."); + } + + /// Returns the value one past the largest value that defines. + /// The enum type. + /// A value that the consumed package does not define. + internal static TEnum Undefined() + where TEnum : struct, Enum + { + long largest = Enum.GetValues() + .Select(static value => Convert.ToInt64(value, CultureInfo.InvariantCulture)) + .Max(); + return (TEnum) Enum.ToObject(typeof(TEnum), largest + 1); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/TamperedValues.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/TamperedValues.cs new file mode 100644 index 0000000..730c26c --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/TamperedValues.cs @@ -0,0 +1,19 @@ +using System.Reflection; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// Builds values that no public constructor or factory can create, as memory tampering would. +internal static class TamperedValues +{ + /// Writes one auto-property backing field of a copy of . + internal static T WithBackingField(T value, string property, object fieldValue) + where T : struct + { + object boxed = value; + FieldInfo field = + typeof(T).GetField($"<{property}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?? + throw new InvalidOperationException($"{typeof(T).Name}.{property} has no backing field."); + field.SetValue(boxed, fieldValue); + return (T) boxed; + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/TargetObservationDouble.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/TargetObservationDouble.cs new file mode 100644 index 0000000..0b7b3a3 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/TargetObservationDouble.cs @@ -0,0 +1,187 @@ +using System.Reflection; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +/// Builds copied CheatEngine.SDK target observations for test doubles. +internal static class TargetObservations +{ + /// The configured pointer size that equals the bitness of the built observation. + internal const int SameAsBitness = int.MinValue; + + /// A protected Lua failure status, as the SDK reports a raising global. + internal static ProcessOperationStatus LuaFailure => + ProcessOperationStatus.ProtectedLuaFailure(LuaStatus.RuntimeError); + + /// Returns the SDK status of a kind, as the SDK constructs it. + internal static ProcessOperationStatus Status(ProcessOperationStatusKind kind) + { + return kind switch + { + ProcessOperationStatusKind.Success => ProcessOperationStatus.Success, + ProcessOperationStatusKind.TargetNotAttached => ProcessOperationStatus.TargetNotAttached, + ProcessOperationStatusKind.SelectionNotConfirmed => ProcessOperationStatus.SelectionNotConfirmed, + ProcessOperationStatusKind.GlobalUnavailable => ProcessOperationStatus.GlobalUnavailable, + ProcessOperationStatusKind.ProtectedLuaFailure => TargetObservations.LuaFailure, + ProcessOperationStatusKind.InvalidResult => ProcessOperationStatus.InvalidResult, + ProcessOperationStatusKind.TargetChanged => ProcessOperationStatus.TargetChanged, + ProcessOperationStatusKind.FileAsProcessTarget => ProcessOperationStatus.FileAsProcessTarget, + _ => default + }; + } + + /// + /// Creates the local process incarnation the SDK would observe. Its constructor is internal to CheatEngine.SDK, so + /// test doubles reach it through reflection; production code only copies incarnations the SDK produced. + /// + internal static TargetProcessIncarnation Incarnation(int processId, long startedAtUtcTicks) + { + ConstructorInfo constructor = typeof(TargetProcessIncarnation).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, [typeof(int), typeof(long)]) ?? throw new InvalidOperationException( + "CheatEngine.SDK no longer declares the TargetProcessIncarnation(int, long) constructor."); + return (TargetProcessIncarnation) constructor.Invoke([processId, startedAtUtcTicks]); + } + + /// Returns the same facts for another selected process. + internal static TargetArchitectureObservation WithProcessId(TargetArchitectureObservation facts, int processId) + { + return new TargetArchitectureObservation(new TargetProcessId(processId), facts.Backend, facts.Bitness, + facts.IsX86Family, facts.IsArmFamily, facts.IsAndroid, facts.AbiCode, facts.ConfiguredPointerSizeBytes); + } + + /// Builds the facts Cheat Engine reports for one selected target (an x64 local process by default). + internal static TargetArchitectureObservation Create( + int processId = 42, + bool is64Bit = true, + bool? isX86Family = true, + bool? isArmFamily = false, + int? configuredPointerSizeBytes = SameAsBitness, + TargetBackend backend = TargetBackend.LocalProcess, + bool? isAndroid = false, + int? abiCode = 0) + { + PointerSize bitness = is64Bit ? PointerSize.Bit64 : PointerSize.Bit32; + int? configured = configuredPointerSizeBytes == SameAsBitness ? bitness.Bytes : configuredPointerSizeBytes; + return new TargetArchitectureObservation(new TargetProcessId(processId), backend, bitness, isX86Family, + isArmFamily, isAndroid, abiCode, configured); + } +} + +/// +/// A configurable : by default an x64 local target (PID 42) whose configured +/// pointer size equals its bitness. The narrowed reads follow the same target unless a sequence is configured. +/// +internal class TargetObservationDouble : ITargetObservationPort +{ + private int _currentReads; + + /// Gets or sets the status of the full target observation. + internal ProcessOperationStatus TargetStatus + { + get; + set; + } = ProcessOperationStatus.Success; + + /// Gets or sets the facts of the selected target. + internal TargetArchitectureObservation Target + { + get; + set; + } = TargetObservations.Create(); + + /// Gets or sets an exception every target observation throws (for example a detached SDK runtime). + internal Exception? TargetFault + { + get; + set; + } + + /// + /// Gets or sets the results of successive ObserveCurrent reads; the last one repeats. When unset, the read + /// reports the target of unless says why no process is selected. + /// + internal (ProcessOperationStatus Status, int ProcessId)[]? CurrentReads + { + get; + set; + } + + /// Gets or sets the status of TryGetConfiguredPointerSize; derived from the target when unset. + internal ProcessOperationStatus? ConfiguredStatus + { + get; + set; + } + + /// Gets the names of the target observations in call order. + internal List TargetCalls + { + get; + } = []; + + public ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) + { + RecordTarget(nameof(ObserveTargetArchitecture)); + observation = TargetStatus.IsSuccess ? Target : default; + return TargetStatus; + } + + public ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) + { + RecordTarget(nameof(ObserveCurrent)); + ProcessOperationStatus status; + int processId; + if (CurrentReads is { Length: > 0 } reads) + { + (status, processId) = reads[Math.Min(_currentReads, reads.Length - 1)]; + } + else + { + status = TargetStatus.Kind is ProcessOperationStatusKind.ProtectedLuaFailure + or ProcessOperationStatusKind.InvalidResult + ? ProcessOperationStatus.Success + : TargetStatus; + processId = Target.ProcessId.Value; + } + + _currentReads++; + observation = status.IsSuccess + ? new CurrentProcessObservation(new TargetProcessId(processId), Target.Bitness) + : default; + return status; + } + + public ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize) + { + RecordTarget(nameof(TryGetConfiguredPointerSize)); + rawBytes = Target.ConfiguredPointerSizeBytes.GetValueOrDefault(); + pointerSize = Target.ConfiguredPointerSize; + return ConfiguredStatus ?? (Target.ConfiguredPointerSizeBytes switch + { + null => ProcessOperationStatus.GlobalUnavailable, + 4 or 8 => ProcessOperationStatus.Success, + _ => ProcessOperationStatus.InvalidResult + }); + } + + /// Counts the calls of one target observation. + internal int CountTarget(string member) + { + return TargetCalls.Count(call => call == member); + } + + private void RecordTarget(string member) + { + TargetCalls.Add(member); + if (TargetFault is { } fault) + { + throw fault; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/packages.lock.json b/tests/CheatEngine.Client.Core.Tests/packages.lock.json index ac542c5..4cd1370 100644 --- a/tests/CheatEngine.Client.Core.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Core.Tests/packages.lock.json @@ -2,17 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -24,6 +13,35 @@ "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, )", @@ -34,6 +52,12 @@ "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, )", @@ -55,12 +79,12 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", "dependencies": { - "System.IO.Hashing": "10.0.12" + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" } }, "Microsoft.DiaSymReader": { @@ -73,11 +97,6 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -113,11 +132,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -184,21 +198,27 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" } } } diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/AutoAssemblerPatchesRegistrationTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/AutoAssemblerPatchesRegistrationTests.cs new file mode 100644 index 0000000..db6b7eb --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/AutoAssemblerPatchesRegistrationTests.cs @@ -0,0 +1,99 @@ +#pragma warning disable CECLIENT5004 // These tests exercise the experimental Auto Assembler opt-in. + +using System.Reflection; + +using CheatEngine.Client.Assembly; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; + +/// +/// EnableAutoAssemblerPatches() is the only path that registers and sets +/// the activation policy, exactly like the unsafe Lua opt-in (plan L17, Q44). +/// +public sealed class AutoAssemblerPatchesRegistrationTests +{ + [Fact] + [Trait("Qualification", "Q44")] + public void WithoutTheOptInNothingIsRegisteredAndThePolicyStaysDisabled() + { + ServiceCollection services = new(); + services.AddCheatEngineClient(); + + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + + Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IAutoAssemblerClient)); + Assert.DoesNotContain(services, + static descriptor => descriptor.ServiceType == typeof(AutoAssemblerPatchesRegistration)); + Assert.Null(provider.GetService()); + Assert.False(ReadPolicy(provider, "EnableAutoAssemblerPatches")); + } + + [Fact] + public void TheOptInRegistersOneClientAndEnablesOnlyItsOwnPolicy() + { + ServiceCollection services = new(); + CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + + Assert.Same(builder, builder.EnableAutoAssemblerPatches().EnableAutoAssemblerPatches()); + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + + ServiceDescriptor client = Assert.Single(services, + static descriptor => descriptor.ServiceType == typeof(IAutoAssemblerClient)); + Assert.Equal(ServiceLifetime.Singleton, client.Lifetime); + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(AutoAssemblerPatchesRegistration)); + Assert.True(ReadPolicy(provider, "EnableAutoAssemblerPatches")); + Assert.False(ReadPolicy(provider, "EnableUnsafeLuaExecution")); + } + + [Fact] + public void AClientRegisteredByAnotherPathIsRefused() + { + ServiceCollection services = new(); + CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + services.AddSingleton(static _ => + throw new InvalidOperationException("A foreign registration must never be resolved.")); + + InvalidOperationException exception = + Assert.Throws(() => builder.EnableAutoAssemblerPatches()); + + Assert.Contains("EnableAutoAssemblerPatches()", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(services, + static descriptor => descriptor.ServiceType == typeof(AutoAssemblerPatchesRegistration)); + } + + [Fact] + public void ConfigurationCannotEnableAutoAssemblerPatches() + { + using ConfigurationManager configuration = new(); + configuration["CheatEngineClient:EnableAutoAssemblerPatches"] = "true"; + ServiceCollection services = new(); + services.AddCheatEngineClient(configuration); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IAutoAssemblerClient)); + Assert.Empty(provider.GetServices()); + Assert.False(ReadPolicy(provider, "EnableAutoAssemblerPatches")); + } + + /// Reads one flag of the activation policy that the provider composes (a Core-internal type). + private static bool ReadPolicy(IServiceProvider provider, string flag) + { + Type policyType = Type.GetType("CheatEngine.Client.Core.Infrastructure.CoreClientPolicy, CheatEngine.Client.Core", + throwOnError: true)!; + object policy = provider.GetRequiredService(policyType); + PropertyInfo property = policyType.GetProperty(flag, BindingFlags.Instance | BindingFlags.NonPublic) ?? + throw new InvalidOperationException($"CoreClientPolicy has no {flag} flag."); + return (bool) property.GetValue(policy)!; + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj index f47d10e..26e3d72 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj @@ -1,5 +1,10 @@ + + + $(NoWarn);CECLIENT5001;CECLIENT5002 + + diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs index 702b9f6..da3008a 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs @@ -17,35 +17,29 @@ public void ValidateAcceptsAnEmptyAllowedRootList() } [Fact] - public void ValidateRejectsMissingOrInvalidMemoryResourceLimits() + public void ValidateRejectsInvalidMemoryResourceLimits() { - ValidateOptionsResult missing = _validator.Validate(null, - new CheatEngineClientOptions { MemoryResourceLimits = null }); - ValidateOptionsResult invalid = _validator.Validate(null, - new CheatEngineClientOptions - { - MemoryResourceLimits = new MemoryResourceLimits - { - MaximumBatchOperationCount = MemoryBatchLimits.MaximumOperations + 1 - } - }); - - Assert.True(missing.Failed); - Assert.Contains("MemoryResourceLimits", missing.FailureMessage, StringComparison.Ordinal); + CheatEngineClientOptions options = new(); + options.MemoryResourceLimits.MaximumBatchOperationCount = MemoryBatchLimits.MaximumOperationCount + 1; + + ValidateOptionsResult invalid = _validator.Validate(null, options); + Assert.True(invalid.Failed); Assert.Contains("MemoryResourceLimits", invalid.FailureMessage, StringComparison.Ordinal); } [Fact] - public void ValidateRejectsNullAllowedRootList() + public void ValidateRejectsANullAllowedRootEntry() { - ValidateOptionsResult result = - _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = null }); + CheatEngineClientOptions options = new(); + options.AllowedTableRoots.Add(null!); + + ValidateOptionsResult result = _validator.Validate(null, options); Assert.True(result.Failed); string? failureMessage = result.FailureMessage; Assert.NotNull(failureMessage); - Assert.Contains("empty array", failureMessage, StringComparison.Ordinal); + Assert.Contains("blank paths", failureMessage, StringComparison.Ordinal); } [Theory] @@ -55,7 +49,7 @@ public void ValidateRejectsNullAllowedRootList() public void ValidateRejectsBlankOrRelativeAllowedRoots(string root) { ValidateOptionsResult result = - _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = [root] }); + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = { root } }); Assert.True(result.Failed); } @@ -67,7 +61,7 @@ public void ValidateRejectsDuplicateNormalizedAllowedRoots() string rootWithTrailingSeparator = root + Path.DirectorySeparatorChar; ValidateOptionsResult result = _validator.Validate(null, - new CheatEngineClientOptions { AllowedTableRoots = [root, rootWithTrailingSeparator] }); + new CheatEngineClientOptions { AllowedTableRoots = { root, rootWithTrailingSeparator } }); Assert.True(result.Failed); string? failureMessage = result.FailureMessage; @@ -82,7 +76,7 @@ public void ValidateAcceptsDistinctFullyQualifiedAllowedRoots() string second = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "two")); ValidateOptionsResult result = - _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = [first, second] }); + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = { first, second } }); Assert.True(result.Succeeded); } @@ -93,7 +87,7 @@ public void ValidateRejectsAnAllowedRootThatCannotBeNormalized() string root = Path.GetPathRoot(Path.GetTempPath()) + "\0"; ValidateOptionsResult result = - _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = [root] }); + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = { root } }); Assert.True(result.Failed); string? failureMessage = result.FailureMessage; diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs index 78d6435..e881a15 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -1,25 +1,21 @@ +#pragma warning disable CECLIENT5003 // These tests compose the experimental instruction client of ICheatEngineClient. + using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; using CheatEngine.SDK.Engine.Values; using Microsoft.Extensions.Configuration; @@ -39,42 +35,127 @@ public void AddCheatEngineClientRegistersDescriptorsThatPassProviderValidationWi Assert.NotNull(builder); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ICheatEngineClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ILocalProcessDiagnostics)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IMemoryBatchClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IProcessClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IMemoryClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IPatternScanner)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IAllocationClient)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IAssemblyClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IRemoteExecutionClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IDebuggerClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IHotkeyClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ITimerClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ISpeedClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IHashingClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IDbvmClient)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IValidateOptions)); Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); Assert.NotNull(provider); } + /// + /// The detailed scan is a member of : DI registers the scanner once, as the Core + /// singleton, and no companion service for its outcomes. + /// [Fact] - public void AddMemoryCodecPreservesTheFirstExplicitRegistration() + public void AddCheatEngineClientRegistersThePatternScannerOnceAndNoOutcomeCompanion() { ServiceCollection services = new(); - CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + services.AddCheatEngineClient(); + ServiceDescriptor scanner = + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IPatternScanner)); + AliasRecordingServiceProvider provider = new(); + + object viaScanner = scanner.ImplementationFactory!(provider); + + Assert.Equal(ServiceLifetime.Singleton, scanner.Lifetime); + Assert.IsType(viaScanner, exactMatch: false); + Type requested = Assert.Single(provider.RequestedTypes); + Assert.Equal("CheatEngine.Client.Core.Domains.PatternScanner", requested.FullName); + Assert.DoesNotContain(services, static descriptor => + descriptor.ServiceType.Namespace == "CheatEngine.Client.Scanning" && + descriptor.ServiceType.Name.Contains("Outcome", StringComparison.Ordinal)); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void AddCheatEngineClientComposesTheOperationalValueScannerAsASingleton() + { + ServiceCollection services = new(); + services.AddCheatEngineClient(); + ServiceDescriptor descriptor = + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IValueScanner)); + AliasRecordingServiceProvider provider = new(); + + object scanner = descriptor.ImplementationFactory!(provider); - builder.AddMemoryCodec(); - builder.AddMemoryCodec(); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + Assert.IsType(scanner, exactMatch: false); + Type requested = Assert.Single(provider.RequestedTypes); + Assert.Equal("CheatEngine.Client.Core.Domains.ValueScanning.ValueScanner", requested.FullName); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void AddCheatEngineClientComposesTheOperationalAllocationClientAsASingleton() + { + ServiceCollection services = new(); + services.AddCheatEngineClient(); + ServiceDescriptor descriptor = + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IAllocationClient)); + AliasRecordingServiceProvider provider = new(); + + object allocations = descriptor.ImplementationFactory!(provider); - ServiceDescriptor descriptor = Assert.Single(services, - static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); - Assert.Equal(typeof(FirstCustomCodec), descriptor.ImplementationType); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + Assert.IsType(allocations, exactMatch: false); + Type requested = Assert.Single(provider.RequestedTypes); + Assert.Equal("CheatEngine.Client.Core.Domains.Allocations.AllocationClient", requested.FullName); + } + + [Fact] + public void AddCheatEngineClientResolvesTheInstructionClientToTheOperationalAssemblyClientSingleton() + { + ServiceCollection services = new(); + services.AddCheatEngineClient(); + ServiceDescriptor descriptor = + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IAssemblyClient)); + AliasRecordingServiceProvider provider = new(); + + object implementation = descriptor.ImplementationFactory!(provider); + + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + Assert.IsType(implementation, exactMatch: false); + Type requested = Assert.Single(provider.RequestedTypes); + Assert.Equal("CheatEngine.Client.Core.Domains.Assembly.AssemblyClient", requested.FullName); + Assert.DoesNotContain("Unavailable", implementation.GetType().FullName!, StringComparison.Ordinal); + } + + /// + /// A5: the Client resolves no codec implicitly. A codec is an ordinary application service, which the plugin passes + /// with each codec request; the registration adds none of its own and the builder has no codec shortcut. + /// + [Fact] + public void AddCheatEngineClientRegistersNoMemoryCodecAndACodecIsAnApplicationService() + { + ServiceCollection services = new(); + services.AddCheatEngineClient(); + + Assert.DoesNotContain(services, static descriptor => + descriptor.ServiceType.IsGenericType && + descriptor.ServiceType.GetGenericTypeDefinition() == typeof(IMemoryCodec<>)); + Assert.DoesNotContain(typeof(CheatEngineClientBuilder).GetMethods(), + static method => method.Name.Contains("Codec", StringComparison.Ordinal)); + + services.AddSingleton, CustomCodec>(); + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + + Assert.IsType(provider.GetRequiredService>()); + Assert.Null(provider.GetService>()); } [Fact] @@ -101,12 +182,16 @@ public void SectionOverloadBindsThenProgrammaticConfigurationRunsLast() ServiceCollection services = new(); services.AddCheatEngineClient(configuration.GetSection("Configured")) - .Configure(options => options.AllowedTableRoots = [overriddenRoot]); + .Configure(options => + { + options.AllowedTableRoots.Clear(); + options.AllowedTableRoots.Add(overriddenRoot); + }); using ServiceProvider provider = services.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.Equal([overriddenRoot], Assert.IsType(options.AllowedTableRoots)); + Assert.Equal([overriddenRoot], options.AllowedTableRoots); } [Fact] @@ -122,7 +207,7 @@ public void RootOverloadUsesTheDefaultClientSection() using ServiceProvider provider = services.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.Equal([allowedRoot], Assert.IsType(options.AllowedTableRoots)); + Assert.Equal([allowedRoot], options.AllowedTableRoots); } [Fact] @@ -136,7 +221,6 @@ public void RootOverloadBindsMemoryResourceLimitsForTheActivationSnapshot() using ServiceProvider provider = services.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.NotNull(options.MemoryResourceLimits); Assert.Equal(37, options.MemoryResourceLimits.MaximumReadBytes); } @@ -164,7 +248,8 @@ public void AddModulePreservesExplicitRegistrationOrder() using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); using IServiceScope scope = provider.CreateScope(); ICheatEngineClientModule[] modules = scope.ServiceProvider.GetServices().ToArray(); @@ -184,7 +269,8 @@ public void AddModuleConstructsAModuleWithScopedDependencyOncePerActivationScope using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); using IServiceScope scope = provider.CreateScope(); ScopedDependencyModule first = Assert.IsType( @@ -207,7 +293,8 @@ public void AddLuaModuleRegistersOneActivationLifecyclePerExplicitDescribedModul using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); ServiceDescriptor[] lifecycleDescriptors = services .Where(static descriptor => descriptor.ServiceType == typeof(ICheatEngineClientModule)) @@ -246,7 +333,10 @@ public void LuaModuleLifecycleForwardsTheClientStoppingTokenToRegistration() RecordingLuaClient lua = new(); LuaModuleLifecycle lifecycle = new(lua, new FirstLuaModule()); using CancellationTokenSource stopping = new(); - TestClient client = new() { Stopping = stopping.Token }; + TestClient client = new() + { + Stopping = stopping.Token + }; lifecycle.OnEnabled(client); @@ -255,30 +345,18 @@ public void LuaModuleLifecycleForwardsTheClientStoppingTokenToRegistration() private readonly record struct CustomValue(int Value); - private sealed class FirstCustomCodec : IMemoryCodec - { - public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) - { - value = default; - return false; - } - - public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) - { - return false; - } - } - - private sealed class SecondCustomCodec : IMemoryCodec + private sealed class CustomCodec : IMemoryCodec { - public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) + public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value, out CheatEngineFailure failure) { + failure = default; value = new CustomValue(2); return true; } - public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) + public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value, out CheatEngineFailure failure) { + failure = default; return value.Value == 2; } } @@ -329,7 +407,7 @@ public void OnDisabling(ICheatEngineClient client) } } - public sealed class FirstLuaModule : IDescribedLuaModule + public sealed class FirstLuaModule : ILuaModule { public LuaModuleDescriptor Descriptor { @@ -340,12 +418,13 @@ public void Register() { } - public void Unregister() + public LuaModuleReleaseOutcome Unregister() { + return LuaModuleReleaseOutcome.Released(Descriptor.Name, 1, 0, 0); } } - public sealed class SecondLuaModule : IDescribedLuaModule + public sealed class SecondLuaModule : ILuaModule { public LuaModuleDescriptor Descriptor { @@ -356,8 +435,9 @@ public void Register() { } - public void Unregister() + public LuaModuleReleaseOutcome Unregister() { + return LuaModuleReleaseOutcome.Released(Descriptor.Name, 1, 0, 0); } } @@ -399,31 +479,34 @@ public bool TryRegisterModule( public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default) { if (TryRegisterModule(luaModule, out ILuaModuleLease? lease, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return lease; } - failure.Throw(); + failure.Throw(cancellationToken); throw new InvalidOperationException("A failed Lua registration must throw its mapped exception."); } - public bool TryExecute( - ILuaOperation operation, + public bool TryExecute( + in TOperation operation, [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation { - ArgumentNullException.ThrowIfNull(operation); result = default; failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Test.Lua", "Not used by this test."); return false; } - public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) + public TResult Execute(in TOperation operation, + CancellationToken cancellationToken = default) + where TOperation : ILuaOperation { - _ = TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken); - failure.Throw(); + _ = TryExecute(in operation, out TResult? result, out CheatEngineFailure failure, + cancellationToken); + failure.Throw(cancellationToken); return result!; } } @@ -436,10 +519,25 @@ internal int DisposeCount private set; } - public long Epoch => 1; - public bool IsReleased => DisposeCount != 0; + public bool RequiresManualRecovery => false; + + public LeaseReleaseOutcome? LastReleaseOutcome => IsReleased + ? new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed) + : null; + + public LuaModuleReleaseOutcome? LastModuleReleaseOutcome => null; + + public LeaseReleaseOutcome Release() + { + bool alreadyReleased = IsReleased; + Dispose(); + return alreadyReleased + ? new LeaseReleaseOutcome(LeaseReleaseKind.AlreadyReleased, CheatEngineHostEffect.NotStarted) + : LastReleaseOutcome!.Value; + } + public void Dispose() { if (DisposeCount == 0) @@ -469,7 +567,7 @@ public CancellationToken Stopping public IPatternScanner Patterns => null!; - public IValueScanner Scans => null!; + public IValueScanner ValueScans => null!; public IInspectionClient Inspection => null!; @@ -480,19 +578,27 @@ public CancellationToken Stopping public IAllocationClient Allocations => null!; public IAssemblyClient Assembly => null!; + } - public IRemoteExecutionClient RemoteExecution => null!; - - public IDebuggerClient Debugger => null!; - - public IHotkeyClient Hotkeys => null!; - - public ITimerClient Timers => null!; + /// + /// Resolves each requested implementation type to one uninitialized singleton so descriptor aliases can be compared + /// without activating Core (which requires an enabled Cheat Engine plugin context). + /// + private sealed class AliasRecordingServiceProvider : IServiceProvider + { + private readonly Dictionary _instances = []; - public ISpeedClient Speed => null!; + internal IReadOnlyCollection RequestedTypes => _instances.Keys; - public IHashingClient Hashing => null!; + public object? GetService(Type serviceType) + { + if (!_instances.TryGetValue(serviceType, out object? instance)) + { + instance = RuntimeHelpers.GetUninitializedObject(serviceType); + _instances.Add(serviceType, instance); + } - public IDbvmClient Dbvm => null!; + return instance; + } } } diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CompositionSurfaceTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CompositionSurfaceTests.cs new file mode 100644 index 0000000..b98af68 --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CompositionSurfaceTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; + +using CheatEngine.Client.Memory; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; + +/// +/// The public surface of the composition layer: typed, never-null options, internal validators, and no implicit +/// memory codec (A5). +/// +public sealed class CompositionSurfaceTests +{ + [Fact] + public void OptionsExposeNonNullTableRootsAndMemoryLimitsWithoutSetters() + { + PropertyInfo roots = typeof(CheatEngineClientOptions).GetProperty( + nameof(CheatEngineClientOptions.AllowedTableRoots))!; + PropertyInfo limits = typeof(CheatEngineClientOptions).GetProperty( + nameof(CheatEngineClientOptions.MemoryResourceLimits))!; + NullabilityInfoContext nullability = new(); + CheatEngineClientOptions options = new(); + + Assert.Equal(typeof(IList), roots.PropertyType); + Assert.Equal(typeof(MemoryResourceLimits), limits.PropertyType); + Assert.Null(roots.SetMethod); + Assert.Null(limits.SetMethod); + Assert.Equal(NullabilityState.NotNull, nullability.Create(roots).ReadState); + Assert.Equal(NullabilityState.NotNull, nullability.Create(limits).ReadState); + Assert.Empty(options.AllowedTableRoots); + Assert.False(options.AllowedTableRoots.IsReadOnly); + Assert.Equal(MemoryResourceLimits.DefaultMaximumReadBytes, options.MemoryResourceLimits.MaximumReadBytes); + } + + [Fact] + public void OptionsValidatorsAreRegisteredButNotPublic() + { + ServiceCollection services = new(); + services.AddCheatEngineClient(); + + Type[] validators = services + .Where(static descriptor => descriptor.ServiceType == typeof(IValidateOptions)) + .Select(static descriptor => descriptor.ImplementationType!) + .ToArray(); + + Assert.Equal([typeof(ValidateCheatEngineClientOptions), typeof(CheatEngineClientOptionsSemanticValidator)], + validators); + Assert.All(validators, static validator => + { + Assert.False(validator.IsVisible, $"{validator.Name} is public."); + Assert.True(validator.IsSealed, $"{validator.Name} is not sealed."); + }); + Assert.DoesNotContain(typeof(CheatEngineClientOptions).Assembly.GetExportedTypes(), + static type => type.GetInterfaces().Any(static contract => + contract.IsGenericType && contract.GetGenericTypeDefinition() == typeof(IValidateOptions<>))); + } + + [Fact] + public void TheCompositionLayerOffersNoCodecShortcutAndNoDefaultCodec() + { + Assert.DoesNotContain(typeof(CheatEngineClientBuilder).GetMethods(), + static method => method.Name == "AddMemoryCodec"); + Assert.DoesNotContain(typeof(CheatEngineClientBuilder).Assembly.GetTypes(), + static type => type.Name == "DefaultMemoryCodecs"); + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs deleted file mode 100644 index bb6c810..0000000 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -using CheatEngine.Client.Memory; -using CheatEngine.SDK.Engine.Values; - -using Microsoft.Extensions.DependencyInjection; - -namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; - -public sealed class DefaultMemoryCodecsTests -{ - [Fact] - public void AddRegistersEveryBuiltInScalarAndPointerCodecExactlyOnce() - { - ServiceCollection services = new(); - - DefaultMemoryCodecs.Add(services); - DefaultMemoryCodecs.Add(services); - - using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions - { - ValidateOnBuild = true, ValidateScopes = true - }); - - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.IsType>(provider.GetRequiredService>(), false); - Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); - Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec
)); - } - - [Fact] - public void ScalarCodecReadsAndWritesTheExactLittleEndianTargetBytes() - { - using ServiceProvider provider = CreateProvider(); - IMemoryCodec codec = provider.GetRequiredService>(); - BufferMemoryContext context = new(8, [0x78, 0x56, 0x34, 0x12]); - Address address = 0x401000; - - Assert.True(codec.TryRead(context, address, out int value)); - Assert.Equal(0x12345678, value); - Assert.Equal(address, context.LastReadAddress); - - Assert.True(codec.TryWrite(context, address, 0x0A0B0C0D)); - Assert.Equal(address, context.LastWriteAddress); - Assert.Equal([0x0D, 0x0C, 0x0B, 0x0A], context.LastWrittenBytes); - } - - [Fact] - public void ScalarCodecReturnsFalseAndTheDefaultValueWhenTheContextCannotFillItsFixedWidthBuffer() - { - using ServiceProvider provider = CreateProvider(); - IMemoryCodec codec = provider.GetRequiredService>(); - BufferMemoryContext context = new(8, [0x01, 0x02, 0x03, 0x04]); - Address address = 0x401080; - - Assert.False(codec.TryRead(context, address, out long value)); - Assert.Equal(0L, value); - Assert.Equal(address, context.LastReadAddress); - } - - [Theory] - [InlineData(4, 0xDEADBEEFul, 0xDEADBEEFu)] - [InlineData(8, 0x1122334455667788ul, 0x1122334455667788ul)] - public void AddressCodecUsesTheTargetPointerWidth(int pointerSize, ulong rawValue, ulong expectedValue) - { - using ServiceProvider provider = CreateProvider(); - IMemoryCodec
codec = provider.GetRequiredService>(); - BufferMemoryContext context = new(pointerSize, pointerSize == 4 - ? [0xEF, 0xBE, 0xAD, 0xDE] - : [0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]); - Address address = 0x401100; - - Assert.True(codec.TryRead(context, address, out Address read)); - Assert.Equal(Address.FromUInt64(expectedValue), read); - - Assert.True(codec.TryWrite(context, address, Address.FromUInt64(rawValue))); - Assert.Equal(pointerSize, context.LastWrittenBytes.Length); - Assert.Equal(pointerSize == 4 - ? [0xEF, 0xBE, 0xAD, 0xDE] - : [0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11], context.LastWrittenBytes); - } - - [Fact] - public void AddressCodecRejectsUnsupportedPointerWidthsAndNarrowingWritesWithoutTouchingMemory() - { - using ServiceProvider provider = CreateProvider(); - IMemoryCodec
codec = provider.GetRequiredService>(); - Address address = 0x401200; - BufferMemoryContext malformedWidth = new(6, [0, 0, 0, 0, 0, 0]); - BufferMemoryContext narrowTarget = new(4, [0, 0, 0, 0]); - - Assert.False(codec.TryRead(malformedWidth, address, out Address malformedRead)); - Assert.Equal(Address.Zero, malformedRead); - Assert.Null(malformedWidth.LastReadAddress); - - Assert.False(codec.TryWrite(malformedWidth, address, Address.FromUInt64(0x1234))); - Assert.Null(malformedWidth.LastWriteAddress); - - Assert.False(codec.TryWrite(narrowTarget, address, Address.FromUInt64(0x1_0000_0000))); - Assert.Null(narrowTarget.LastWriteAddress); - } - - private static ServiceProvider CreateProvider() - { - ServiceCollection services = new(); - DefaultMemoryCodecs.Add(services); - return services.BuildServiceProvider(); - } - - private sealed class BufferMemoryContext(int pointerSize, byte[] bytes) : IMemoryReadContext, IMemoryWriteContext - { - private readonly byte[] _bytes = bytes; - - internal Address? LastReadAddress - { - get; - private set; - } - - internal Address? LastWriteAddress - { - get; - private set; - } - - internal byte[] LastWrittenBytes - { - get; - private set; - } = []; - - public int PointerSize - { - get; - } = pointerSize; - - public bool TryReadBytes(Address address, Span destination) - { - LastReadAddress = address; - if (_bytes.Length < destination.Length) - { - return false; - } - - _bytes.AsSpan(0, destination.Length).CopyTo(destination); - return true; - } - - public bool TryWriteBytes(Address address, ReadOnlySpan source) - { - LastWriteAddress = address; - LastWrittenBytes = source.ToArray(); - return true; - } - } -} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/LoggerCoreDiagnosticsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/LoggerCoreDiagnosticsTests.cs new file mode 100644 index 0000000..42dbb5d --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/LoggerCoreDiagnosticsTests.cs @@ -0,0 +1,358 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Runtime; + +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; + +/// +/// The logging sink of the Core diagnostic events (audit ch.24): one category per domain, stable event ids, one +/// capability refusal per capability and operation, redacted fields, and no provider fault ever escaping. +/// +public sealed partial class LoggerCoreDiagnosticsTests +{ + [Fact] + public void ThrowingLoggerProviderDoesNotEscapeDispatch() + { + ThrowingLoggerProvider fromIsEnabled = new(ThrowingStage.IsEnabled); + ThrowingLoggerProvider fromLog = new(ThrowingStage.Log); + ThrowingLoggerProvider fromCreateLogger = new(ThrowingStage.CreateLogger); + + foreach (ThrowingLoggerProvider provider in new[] { fromIsEnabled, fromLog, fromCreateLogger }) + { + using ILoggerFactory factory = CreateFactory(provider); + LoggerCoreDiagnostics diagnostics = new(factory); + + EmitScriptedRun(diagnostics); + } + + Assert.Equal(ScriptedEventCount, fromIsEnabled.IsEnabledCalls); + Assert.Equal(0, fromIsEnabled.LogCalls); + Assert.Equal(ScriptedEventCount, fromLog.LogCalls); + Assert.Equal(9, fromCreateLogger.CreateLoggerCalls); + Assert.Equal(0, fromCreateLogger.LogCalls); + } + + [Fact] + public void EachDiagnosticEventUsesItsDomainCategoryAndStableEventId() + { + CapturingLoggerProvider logs = new(); + using ILoggerFactory factory = CreateFactory(logs); + + EmitScriptedRun(new LoggerCoreDiagnostics(factory)); + + Assert.Equal( + [ + new EventShape(1000, "RuntimeSnapshotCaptured", "CheatEngine.Client.Runtime", LogLevel.Debug), + new EventShape(1001, "CapabilityRefused", "CheatEngine.Client.Runtime", LogLevel.Debug), + new EventShape(1100, "TargetSelectionAdvanced", "CheatEngine.Client.Processes", LogLevel.Debug), + new EventShape(1200, "PointerWidthMismatchRefused", "CheatEngine.Client.Memory", LogLevel.Information), + new EventShape(1201, "MemoryBatchCompleted", "CheatEngine.Client.Memory", LogLevel.Debug), + new EventShape(1300, "TableGenerationAdvanced", "CheatEngine.Client.Tables", LogLevel.Debug), + new EventShape(1301, "StaleRecordIdentifierRefused", "CheatEngine.Client.Tables", LogLevel.Debug), + new EventShape(1302, "RecordActivationNotApplied", "CheatEngine.Client.Tables", LogLevel.Debug), + new EventShape(1400, "SymbolRegistrationRejected", "CheatEngine.Client.Inspection", LogLevel.Debug), + new EventShape(1500, "PatternScanCompleted", "CheatEngine.Client.Scanning", LogLevel.Debug), + new EventShape(1600, "LuaOperationCompleted", "CheatEngine.Client.Lua", LogLevel.Debug), + new EventShape(1700, "CoreResourceCleanupFailed", "CheatEngine.Client.Lifetime", LogLevel.Warning), + new EventShape(1701, "LeaseReleased", "CheatEngine.Client.Lifetime", LogLevel.Debug), + new EventShape(1800, "AutoAssemblerPatchAppliedAfterTargetChange", "CheatEngine.Client.Assembly", + LogLevel.Warning) + ], + logs.Entries.Select(static entry => new EventShape(entry.EventId.Id, entry.EventId.Name, entry.Category, + entry.Level))); + Assert.Contains(logs.Entries, static entry => entry.Message == + "Memory.ReadPrimitive was refused: Cheat Engine's configured " + + "pointer size (4 byte(s)) differs from the target process " + + "width (8 byte(s))."); + } + + [Fact] + public void DomainCategoriesFollowTheStandardLogLevelFilters() + { + CapturingLoggerProvider logs = new(); + using ILoggerFactory factory = LoggerFactory.Create(logging => logging + .SetMinimumLevel(LogLevel.Information) + .AddFilter(LoggerCoreDiagnostics.TablesCategory, LogLevel.Debug) + .AddFilter(LoggerCoreDiagnostics.MemoryCategory, LogLevel.None) + .AddProvider(logs)); + + EmitScriptedRun(new LoggerCoreDiagnostics(factory)); + + Assert.Equal( + [1300, 1301, 1302, 1700, 1800], + logs.Entries.Select(static entry => entry.EventId.Id)); + } + + [Fact] + public void CapabilityRefusalIsLoggedOncePerCapabilityAndOperation() + { + CapturingLoggerProvider logs = new(); + using ILoggerFactory factory = CreateFactory(logs); + LoggerCoreDiagnostics diagnostics = new(factory); + + // The refusals Core emits: the policy gate of the two opt-in capabilities, per public operation. + for (int attempt = 0; attempt < 3; attempt++) + { + diagnostics.CapabilityRefused("Client.AutoAssemblerPatches", "AutoAssembler.ApplyPatch", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + diagnostics.CapabilityRefused("Client.AutoAssemblerPatches", "AutoAssembler.Check", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + diagnostics.CapabilityRefused("Client.UnsafeLuaExecution", "UnsafeLua.Execute", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + } + + Assert.Equal( + [ + "Capability Client.AutoAssemblerPatches refused AutoAssembler.ApplyPatch: the Policy gate is Missing.", + "Capability Client.AutoAssemblerPatches refused AutoAssembler.Check: the Policy gate is Missing.", + "Capability Client.UnsafeLuaExecution refused UnsafeLua.Execute: the Policy gate is Missing." + ], + logs.Entries.Select(static entry => entry.Message)); + LoggerCoreDiagnostics nextActivation = new(factory); + nextActivation.CapabilityRefused("Client.AutoAssemblerPatches", "AutoAssembler.ApplyPatch", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + Assert.Equal(4, logs.Entries.Count); + } + + [Fact] + public void CapabilityRefusalIsLoggedLaterWhenTheProviderFaultedOnTheFirstEmission() + { + // A provider fault is contained, and the refusal is not counted as logged: the next refusal of the same + // capability and operation is emitted once. + CapturingLoggerProvider logs = new() + { + FailingLogs = 1 + }; + using ILoggerFactory factory = CreateFactory(logs); + LoggerCoreDiagnostics diagnostics = new(factory); + + for (int attempt = 0; attempt < 3; attempt++) + { + diagnostics.CapabilityRefused("Client.UnsafeLuaExecution", "UnsafeLua.Execute", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + } + + Assert.Equal(0, logs.FailingLogs); + Assert.Equal(["Capability Client.UnsafeLuaExecution refused UnsafeLua.Execute: the Policy gate is Missing."], + logs.Entries.Select(static entry => entry.Message)); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void DiagnosticEventsCarryNoAddressValueSymbolOrPath() + { + CapturingLoggerProvider logs = new(); + using ILoggerFactory factory = CreateFactory(logs); + + EmitScriptedRun(new LoggerCoreDiagnostics(factory)); + + Assert.Equal(ScriptedEventCount, logs.Entries.Count); + Assert.All(typeof(LoggerCoreDiagnostics).GetMethods(BindingFlags.Instance | BindingFlags.Public | + BindingFlags.DeclaredOnly) + .SelectMany(static method => method.GetParameters()), + static parameter => Assert.True( + parameter.ParameterType == typeof(string) || parameter.ParameterType == typeof(int) || + parameter.ParameterType == typeof(long) || parameter.ParameterType == typeof(bool) || + parameter.ParameterType.IsEnum, + $"{parameter.Member.Name}.{parameter.Name} has type {parameter.ParameterType.FullName}.")); + foreach (LogEntry entry in logs.Entries) + { + Assert.Null(entry.Exception); + Assert.DoesNotMatch(HexAddress(), entry.Message); + Assert.DoesNotMatch(PathLike(), entry.Message); + Assert.DoesNotContain("player_health", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("readInteger", entry.Message, StringComparison.Ordinal); + foreach (KeyValuePair field in entry.Fields) + { + Assert.True(field.Value is string or int or long or bool or Enum, + $"Event {entry.EventId.Id} field {field.Key} has type {field.Value?.GetType().FullName}."); + } + } + } + + private const int ScriptedEventCount = 14; + + /// + /// Emits one event of each kind with the closed values Core passes (see Core's CoreDiagnosticsTests scripted run). + /// + private static void EmitScriptedRun(LoggerCoreDiagnostics diagnostics) + { + diagnostics.RuntimeSnapshotCaptured(17, CheatEngineArchitecture.X64, 8, 8, false); + diagnostics.CapabilityRefused("Client.AutoAssemblerPatches", "AutoAssembler.ApplyPatch", + ClientCapabilityEvidenceReasonCode.Policy, ClientCapabilityEvidenceState.Missing); + diagnostics.TargetSelectionAdvanced(17, 2, "Processes.Attach", "PidChanged"); + diagnostics.PointerWidthMismatchRefused("Memory.ReadPrimitive", 8, 4); + diagnostics.MemoryBatchCompleted("Memory.WritePrimitiveBatch", 12, 5, "Partial"); + diagnostics.TableGenerationAdvanced(17, 1); + diagnostics.StaleRecordIdentifierRefused("Tables.SetActive", 1); + diagnostics.RecordActivationNotApplied("Tables.SetActive", true, "RefusedByHost"); + diagnostics.SymbolRegistrationRejected("Inspection.RegisterSymbol", "AlreadyResolves"); + diagnostics.PatternScanCompleted(PatternScanScope.GlobalHostScanWithManagedFilter, 40L, 10, true, 250, 3); + diagnostics.LuaOperationCompleted("UnsafeLua.Execute", "LuaError", 4, 36); + diagnostics.CoreResourceCleanupFailed("SymbolRegistrationLease", + "CheatEngine.Client.Results.CheatEngineOperationException"); + diagnostics.LeaseReleased("Allocations.Release", LeaseReleaseKind.RefusedTargetChanged, + CheatEngineHostEffect.NotStarted); + diagnostics.AutoAssemblerPatchAppliedAfterTargetChange("AutoAssembler.ApplyPatch", 2); + } + + private static ILoggerFactory CreateFactory(ILoggerProvider provider) + { + return LoggerFactory.Create(logging => logging.SetMinimumLevel(LogLevel.Trace).AddProvider(provider)); + } + + [GeneratedRegex(@"(?i)\b(0x)?[0-9a-f]{8,}\b")] + private static partial Regex HexAddress(); + + [GeneratedRegex(@"[A-Za-z]:\\|\\\\|/|\.ct\b|\.exe\b|\.dll\b")] + private static partial Regex PathLike(); + + private sealed record EventShape(int Id, string? Name, string Category, LogLevel Level); + + private sealed record LogEntry( + string Category, + EventId EventId, + LogLevel Level, + string Message, + Exception? Exception, + IReadOnlyList> Fields); + + private enum ThrowingStage + { + CreateLogger, + IsEnabled, + Log + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + private readonly Lock _gate = new(); + private readonly List _entries = []; + private int _failingLogs; + + /// The number of next Log calls that throw instead of capturing. + internal int FailingLogs + { + get => Volatile.Read(ref _failingLogs); + init => _failingLogs = value; + } + + internal IReadOnlyList Entries + { + get + { + lock (_gate) + { + return [.. _entries]; + } + } + } + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(this, categoryName); + } + + public void Dispose() + { + } + + private void Add(LogEntry entry) + { + if (Interlocked.Decrement(ref _failingLogs) >= 0) + { + throw new InvalidOperationException("The logging provider failed once."); + } + + Interlocked.Exchange(ref _failingLogs, 0); + lock (_gate) + { + _entries.Add(entry); + } + } + + private sealed class CapturingLogger(CapturingLoggerProvider owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + IReadOnlyList> fields = + state is IReadOnlyList> structured + ? [.. structured.Where(static field => field.Key != "{OriginalFormat}")] + : []; + owner.Add(new LogEntry(category, eventId, logLevel, formatter(state, exception), exception, fields)); + } + } + } + + private sealed class ThrowingLoggerProvider(ThrowingStage stage) : ILoggerProvider + { + private int _createLoggerCalls; + private int _isEnabledCalls; + private int _logCalls; + + internal int CreateLoggerCalls => Volatile.Read(ref _createLoggerCalls); + + internal int IsEnabledCalls => Volatile.Read(ref _isEnabledCalls); + + internal int LogCalls => Volatile.Read(ref _logCalls); + + public ILogger CreateLogger(string categoryName) + { + Interlocked.Increment(ref _createLoggerCalls); + return stage == ThrowingStage.CreateLogger + ? throw new InvalidOperationException("The logging provider could not create a logger.") + : new ThrowingLogger(this); + } + + public void Dispose() + { + } + + private sealed class ThrowingLogger(ThrowingLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + Interlocked.Increment(ref owner._isEnabledCalls); + return owner.ThrowsFrom(ThrowingStage.IsEnabled) + ? throw new InvalidOperationException("The logging filter failed.") + : true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + Interlocked.Increment(ref owner._logCalls); + throw new InvalidOperationException("The logging provider failed."); + } + } + + private bool ThrowsFrom(ThrowingStage candidate) + { + return stage == candidate; + } + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md index 60c47cf..cf9fee7 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md @@ -7,15 +7,22 @@ activation-scoped CheatEngine.Client facade. ## Why this project exists -DI is the public assembly point for options, logging, default memory codecs, custom codecs, modules, and the policy -that controls optional capabilities. The registration graph must stay deterministic and safe without a Generic Host or -a process-wide service provider. +DI is the composition layer of Hosting: it assembles options, logging, modules, and the policy that controls optional +capabilities. The registration graph must stay deterministic and safe without a Generic Host or a process-wide service +provider. ## How it helps improve CheatEngine.Client -The suite verifies registration completeness, semantic options validation, explicit codec selection, and module -ordering. It catches accidental singleton leakage, invalid configuration defaults, or registration changes that would -make an otherwise valid plugin fail during enable. +The suite verifies registration completeness, semantic options validation, the absence of any implicit memory codec +(A5), and module ordering. It catches accidental singleton leakage, invalid configuration defaults, or registration +changes that would make an otherwise valid plugin fail during enable. + +`CompositionSurfaceTests` pins the public surface of the layer: `AllowedTableRoots` is a read-only, never-null +`IList`, `MemoryResourceLimits` is never null, the options validators are internal, and no `AddMemoryCodec` or +default codec exists. +`LoggerCoreDiagnosticsTests` pins the Core diagnostic events: one logger category per domain, stable event ids +1000–1800, one capability refusal per capability and operation, standard `Logging:LogLevel` filtering, no address, +value, symbol or path in any event (Q46), and no logging-provider fault escaping. ## Run diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json index f242f60..b863028 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json @@ -2,17 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -24,6 +13,35 @@ "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, )", @@ -34,6 +52,12 @@ "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, )", @@ -55,12 +79,12 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", "dependencies": { - "System.IO.Hashing": "10.0.12" + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" } }, "Microsoft.DiaSymReader": { @@ -96,11 +120,6 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -136,11 +155,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -207,21 +221,21 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -231,9 +245,9 @@ }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive", diff --git a/tests/CheatEngine.Client.Fluent.Tests/FluentSurfaceTests.cs b/tests/CheatEngine.Client.Fluent.Tests/FluentSurfaceTests.cs new file mode 100644 index 0000000..131dff2 --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/FluentSurfaceTests.cs @@ -0,0 +1,211 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +using CheatEngine.Client.Memory; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Fluent.Tests; + +/// +/// Pins the shape of the Fluent public surface: one bound entry point per domain, and builders that are plain readonly +/// structs whose default value rejects its operations with a documented . +/// +public sealed class FluentSurfaceTests +{ + private static readonly Type[] FluentTypes = typeof(CheatEngineMemoryFluentExtensions).Assembly.GetExportedTypes(); + + private static readonly Type[] Builders = + [ + typeof(MemoryAddressBuilder), typeof(MemoryPointerChainBuilder), typeof(MemoryPrimitiveBatchBuilder<>), + typeof(AobScanBuilder), typeof(AobFirstMatchBuilder), typeof(AobManyMatchBuilder), typeof(AobSingleMatchBuilder) + ]; + + [Fact] + public void EveryFluentDomainHasOneBoundEntryPoint() + { + Dictionary expected = new(StringComparer.Ordinal) + { + ["CheatEngineAobFluentExtensions.Aob(IPatternScanner, AobPattern)"] = nameof(AobScanBuilder), + ["CheatEngineAobFluentExtensions.Aob(IPatternScanner, String)"] = nameof(AobScanBuilder), + ["CheatEngineMemoryFluentExtensions.At(IMemoryClient, Address)"] = nameof(MemoryAddressBuilder), + ["CheatEngineMemoryFluentExtensions.Batch(IMemoryClient)"] = "MemoryPrimitiveBatchBuilder`1" + }; + + Type[] entryPointClasses = [.. FluentTypes.Where(static type => type.IsAbstract && type.IsSealed)]; + Dictionary actual = entryPointClasses + .SelectMany(static type => + type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)) + .ToDictionary(Describe, static method => method.ReturnType.Name, StringComparer.Ordinal); + + Assert.Equal(expected, actual); + Assert.Equal(FluentTypes.Length, entryPointClasses.Length + Builders.Length); + Assert.All(entryPointClasses, static type => Assert.True(type.IsDefined(typeof(ExtensionAttribute), false))); + } + + [Fact] + public void BuildersCanOnlyComeFromABoundEntryPoint() + { + Assert.All(Builders, static builder => + { + Assert.Contains(builder, FluentTypes); + Assert.Empty(builder.GetConstructors()); + Assert.DoesNotContain(builder.GetMethods(BindingFlags.Public | BindingFlags.Static), IsBuilderFactory); + Assert.DoesNotContain(builder.GetMethods(BindingFlags.Public | BindingFlags.Instance), + static method => method.Name == "Using"); + }); + } + + [Fact] + public void BuildersArePlainReadonlyStructsWithoutPublicEquality() + { + string[] equalityMembers = ["Equals", "GetHashCode", "ToString", "op_Equality", "op_Inequality"]; + string[] recordMembers = ["PrintMembers", "EqualityContract", "$", "Deconstruct"]; + + Assert.All(Builders, builder => + { + MemberInfo[] declared = builder.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly); + + Assert.True(builder.IsValueType, $"{builder.Name} is not a struct."); + Assert.True(builder.IsDefined(typeof(IsReadOnlyAttribute), false), $"{builder.Name} is not readonly."); + Assert.DoesNotContain(declared, member => equalityMembers.Contains(member.Name) && IsPublic(member)); + Assert.DoesNotContain(declared, member => recordMembers.Contains(member.Name)); + Assert.DoesNotContain(builder.GetInterfaces(), static contract => + contract.IsGenericType && contract.GetGenericTypeDefinition() == typeof(IEquatable<>)); + }); + } + + [Fact] + public void EveryDefaultBuilderRejectsItsOperationsAndNamesItsEntryPoint() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + Action[] operations = + [ + () => default(MemoryAddressBuilder).Read(cancellationToken), + () => default(MemoryAddressBuilder).Follow([0x10L]), + () => default(MemoryPointerChainBuilder).Resolve(cancellationToken), + () => default(MemoryPrimitiveBatchBuilder).Read([], cancellationToken), + () => default(AobScanBuilder).FirstOrNone(), + () => default(AobScanBuilder).RequireSingle(), + () => default(AobScanBuilder).Take(1), + () => default(AobFirstMatchBuilder).TryExecute(out _, out _, cancellationToken), + () => default(AobManyMatchBuilder).TryExecute(out _, out _, cancellationToken), + () => default(AobSingleMatchBuilder).TryExecute(out _, out _, cancellationToken) + ]; + + Assert.All(operations, static operation => + { + InvalidOperationException exception = Assert.Throws(operation); + Assert.Contains("is a default value without", exception.Message, StringComparison.Ordinal); + Assert.Matches(@"client\.(Memory|Patterns)\.(At|Batch|Aob)", exception.Message); + }); + } + + [Fact] + public void EveryBuilderOperationDocumentsTheDefaultBuilderException() + { + XDocument documentation = + XDocument.Load(Path.ChangeExtension(typeof(MemoryAddressBuilder).Assembly.Location, ".xml")); + XElement[] members = [.. documentation.Descendants("member")]; + + // Every operation but the configuration methods, which return a new builder of their own type. + string[] operations = + [ + .. Builders.SelectMany(static builder => builder + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => !method.IsSpecialName && method.ReturnType != builder) + .Select(method => $"M:{builder.FullName}.{method.Name}")) + .Distinct(StringComparer.Ordinal) + ]; + + Assert.NotEmpty(operations); + Assert.All(operations, operation => + { + XElement[] documented = [.. members.Where(member => IsDocumentationOf((string) member.Attribute("name")!, + operation))]; + + Assert.NotEmpty(documented); + Assert.All(documented, static member => Assert.Contains(member.Elements("exception"), static exception => + (string?) exception.Attribute("cref") == "T:System.InvalidOperationException")); + }); + } + + /// + /// Every terminal documents the Client exceptions it can raise: a throwing terminal the four types that + /// CheatEngineFailure.Throw maps a failure to, and a Try terminal, which returns the failure, the two + /// lifecycle exceptions that the bound service throws once the activation has ended or while it is stopping. + /// + [Fact] + public void EveryTerminalDocumentsTheClientExceptionsItCanRaise() + { + XDocument documentation = + XDocument.Load(Path.ChangeExtension(typeof(MemoryAddressBuilder).Assembly.Location, ".xml")); + XElement[] members = [.. documentation.Descendants("member")]; + string[] lifecycle = + [ + "T:CheatEngine.Client.Results.CheatEngineActivationExpiredException", + "T:CheatEngine.Client.Results.CheatEngineInvalidStateException" + ]; + string[] throwing = + [ + .. lifecycle, "T:CheatEngine.Client.Results.CheatEngineOperationException", + "T:CheatEngine.Client.Results.CheatEngineOperationCanceledException" + ]; + + // Every operation that runs work: configuration and terminal selection return a builder. + string[] terminals = + [ + .. Builders.SelectMany(static builder => builder + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(static method => !method.IsSpecialName && !IsBuilder(method.ReturnType)) + .Select(method => $"M:{builder.FullName}.{method.Name}")) + .Distinct(StringComparer.Ordinal) + ]; + + Assert.Equal(28, terminals.Length); + Assert.All(terminals, terminal => + { + bool isTry = terminal[(terminal.LastIndexOf('.') + 1)..].StartsWith("Try", StringComparison.Ordinal); + XElement[] documented = [.. members.Where(member => IsDocumentationOf((string) member.Attribute("name")!, + terminal))]; + + Assert.NotEmpty(documented); + Assert.All(documented, member => + { + string[] exceptions = + [.. member.Elements("exception").Select(static exception => (string) exception.Attribute("cref")!)]; + Assert.All(isTry ? lifecycle : throwing, expected => Assert.Contains(expected, exceptions)); + }); + }); + } + + private static bool IsBuilder(Type type) + { + return Builders.Contains(type.IsGenericType ? type.GetGenericTypeDefinition() : type); + } + + private static bool IsDocumentationOf(string memberId, string operation) + { + return memberId == operation || memberId.StartsWith(operation + "(", StringComparison.Ordinal) || + memberId.StartsWith(operation + "``", StringComparison.Ordinal); + } + + private static bool IsPublic(MemberInfo member) + { + return member is MethodBase { IsPublic: true } or PropertyInfo { GetMethod.IsPublic: true }; + } + + private static bool IsBuilderFactory(MethodInfo method) + { + return IsBuilder(method.ReturnType); + } + + private static string Describe(MethodInfo method) + { + Assert.True(method.IsDefined(typeof(ExtensionAttribute), false), $"{method} is not an extension method."); + return $"{method.DeclaringType!.Name}.{method.Name}(" + + string.Join(", ", method.GetParameters().Select(static parameter => parameter.ParameterType.Name)) + ")"; + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs index 1421b59..c0a64d9 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -5,39 +5,43 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Engine.Values; -using MemoryFluent = CheatEngine.Client.Memory.Memory; - namespace CheatEngine.Client.Fluent.Tests.Memory; public sealed class MemoryAddressBuilderTests { [Fact] - public void AtWithoutServiceRejectsABuiltInTerminalOperation() + public void DefaultBuilderRejectsABuiltInTerminalOperationAndNamesTheBoundEntryPoint() { - MemoryAddressBuilder builder = MemoryFluent.At(0x401000UL); + MemoryAddressBuilder builder = default; InvalidOperationException exception = Assert.Throws(() => builder.Read(TestContext.Current.CancellationToken)); - Assert.Contains("Memory.At(memory, address)", exception.Message); Assert.Contains("memory.At(address)", exception.Message); - Assert.Contains("Using(memory)", exception.Message); - Assert.DoesNotContain("pass the service to Read/Write", exception.Message); + Assert.DoesNotContain("Memory.At(memory, address)", exception.Message); + Assert.DoesNotContain("Using(", exception.Message); } [Fact] - public void UsingCreatesABoundBuilderAndForwardsBuiltInReads() + public void AtBindsTheBuilderToTheMemoryServiceAndForwardsBuiltInReads() { Address address = 0x401000; FakeMemoryClient memory = new(1337); - MemoryAddressBuilder unbound = MemoryFluent.At(address); - int value = unbound.Using(memory).Read(TestContext.Current.CancellationToken); + int value = memory.At(address).Read(TestContext.Current.CancellationToken); Assert.Equal(1337, value); Assert.Equal(address, memory.LastPrimitiveReadAddress); Assert.Equal(typeof(int), memory.LastPrimitiveReadType); - Assert.Throws(() => unbound.Read(TestContext.Current.CancellationToken)); + } + + [Fact] + public void TheMemoryEntryPointsRejectANullService() + { + IMemoryClient memory = null!; + + Assert.Throws(() => memory.At(0x401000UL)); + Assert.Throws(() => memory.Batch()); } [Fact] @@ -63,7 +67,7 @@ public void ReadWithForwardsTheExplicitCustomCodecWithoutReplacingIt() Int32Codec codec = new(); FakeMemoryClient memory = new(42); - int value = MemoryFluent.At(address).Using(memory).ReadWith(codec, TestContext.Current.CancellationToken); + int value = memory.At(address).ReadWith(codec, TestContext.Current.CancellationToken); Assert.Equal(42, value); Assert.Equal(address, memory.LastCustomReadAddress); @@ -77,7 +81,7 @@ public void TryWriteWithForwardsValueAddressAndTheExplicitCustomCodec() Int32Codec codec = new(); FakeMemoryClient memory = new(0); - bool succeeded = MemoryFluent.At(address).Using(memory).TryWriteWith( + bool succeeded = memory.At(address).TryWriteWith( 77, codec, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.True(succeeded); @@ -93,7 +97,7 @@ public void TryReadForwardsBuiltInReadToTheBoundService() Address address = 0x405000; FakeMemoryClient memory = new(1337); - bool succeeded = MemoryFluent.At(address).Using(memory).TryRead( + bool succeeded = memory.At(address).TryRead( out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.True(succeeded); @@ -109,7 +113,7 @@ public void WriteForwardsBuiltInWriteToTheBoundService() Address address = 0x406000; FakeMemoryClient memory = new(0); - MemoryFluent.At(address).Using(memory).Write(77, TestContext.Current.CancellationToken); + memory.At(address).Write(77, TestContext.Current.CancellationToken); Assert.Equal(address, memory.LastPrimitiveWriteAddress); Assert.Equal(typeof(int), memory.LastPrimitiveWriteType); @@ -123,7 +127,7 @@ public void TryReadWithForwardsTheBoundCustomCodecWithoutReplacingIt() Int32Codec codec = new(); FakeMemoryClient memory = new(42); - bool succeeded = MemoryFluent.At(address).Using(memory).TryReadWith( + bool succeeded = memory.At(address).TryReadWith( codec, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.True(succeeded); @@ -134,35 +138,16 @@ public void TryReadWithForwardsTheBoundCustomCodecWithoutReplacingIt() } [Fact] - public void TryReadWithForwardsTheExplicitMemoryServiceAndCustomCodec() + public void TryReadWithRejectsADefaultBuilderAndANullCodec() { - Address address = 0x408000; + MemoryAddressBuilder unbound = default; + MemoryAddressBuilder bound = new FakeMemoryClient(0).At(0x409000UL); Int32Codec codec = new(); - FakeMemoryClient memory = new(42); - - bool succeeded = MemoryFluent.At(address).TryReadWith( - memory, codec, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); - - Assert.True(succeeded); - Assert.Equal(42, value); - Assert.Equal(default, failure); - Assert.Equal(address, memory.LastCustomReadAddress); - Assert.Same(codec, memory.LastCustomReadCodec); - } - - [Fact] - public void TryReadWithRejectsMissingBoundMemoryAndNullExplicitArguments() - { - MemoryAddressBuilder unbound = MemoryFluent.At(0x409000UL); - Int32Codec codec = new(); - FakeMemoryClient memory = new(0); Assert.Throws(() => unbound.TryReadWith( codec, out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => unbound.TryReadWith( - null!, codec, out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => unbound.TryReadWith( - memory, null!, out int _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => bound.TryReadWith( + (IMemoryCodec) null!, out _, out _, TestContext.Current.CancellationToken)); } [Fact] @@ -172,21 +157,7 @@ public void WriteWithForwardsTheBoundCustomCodecWithoutReplacingIt() Int32Codec codec = new(); FakeMemoryClient memory = new(0); - MemoryFluent.At(address).Using(memory).WriteWith(77, codec, TestContext.Current.CancellationToken); - - Assert.Equal(address, memory.LastCustomWriteAddress); - Assert.Equal(77, memory.LastCustomWriteValue); - Assert.Same(codec, memory.LastCustomWriteCodec); - } - - [Fact] - public void WriteWithForwardsTheExplicitMemoryServiceAndCustomCodec() - { - Address address = 0x40B000; - Int32Codec codec = new(); - FakeMemoryClient memory = new(0); - - MemoryFluent.At(address).WriteWith(memory, 77, codec, TestContext.Current.CancellationToken); + memory.At(address).WriteWith(77, codec, TestContext.Current.CancellationToken); Assert.Equal(address, memory.LastCustomWriteAddress); Assert.Equal(77, memory.LastCustomWriteValue); @@ -194,18 +165,16 @@ public void WriteWithForwardsTheExplicitMemoryServiceAndCustomCodec() } [Fact] - public void WriteWithRejectsMissingBoundMemoryAndNullExplicitArguments() + public void WriteWithRejectsADefaultBuilderAndANullCodec() { - MemoryAddressBuilder unbound = MemoryFluent.At(0x40C000UL); + MemoryAddressBuilder unbound = default; + MemoryAddressBuilder bound = new FakeMemoryClient(0).At(0x40C000UL); Int32Codec codec = new(); - FakeMemoryClient memory = new(0); Assert.Throws(() => unbound.WriteWith(77, codec, TestContext.Current.CancellationToken)); - Assert.Throws(() => unbound.WriteWith( - null!, 77, codec, TestContext.Current.CancellationToken)); - Assert.Throws(() => unbound.WriteWith( - memory, 77, null!, TestContext.Current.CancellationToken)); + Assert.Throws(() => + bound.WriteWith(77, (IMemoryCodec) null!, TestContext.Current.CancellationToken)); } [Fact] @@ -215,8 +184,8 @@ public void BoundedBytesAndStringsProduceCopiedRequestsWithExplicitEncoding() MemoryAddressBuilder builder = memory.At(0x40D000); ImmutableArray bytes = builder.ReadBytes(2, TestContext.Current.CancellationToken); - builder.WriteUtf8("é", 2, TestContext.Current.CancellationToken); - string text = builder.ReadUtf16(32, TestContext.Current.CancellationToken); + builder.WriteString("é", 2, MemoryStringEncoding.Utf8, TestContext.Current.CancellationToken); + string text = builder.ReadString(32, MemoryStringEncoding.Utf16, TestContext.Current.CancellationToken); Assert.Equal([0x10, 0x20], bytes); Assert.Equal(2, memory.LastBytesReadRequest.Length); @@ -244,16 +213,53 @@ public void PointerChainsAndPrimitiveBatchesRemainBoundedAndUseOneTerminalContra Assert.Equal(2, memory.LastPrimitiveBatchReadCount); } + /// + /// Every memory terminal passes the caller's token unchanged, so the service's cancellation surfaces as an + /// carrying that token. + /// + [Theory] + [InlineData("Read")] + [InlineData("Resolve")] + [InlineData("Batch")] + public void ACancelledTokenReachesTheServiceAndSurfacesAsOperationCanceledException(string terminal) + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + FakeMemoryClient memory = new(1337); + Address address = 0x40F000; + + OperationCanceledException exception = Assert.ThrowsAny(() => + { + switch (terminal) + { + case "Read": + _ = memory.At(address).Read(cancellation.Token); + break; + case "Resolve": + _ = memory.At(address).Follow([0x10L]).Resolve(cancellation.Token); + break; + default: + _ = memory.Batch().Read([address], cancellation.Token); + break; + } + }); + + Assert.IsType(exception); + Assert.Equal(cancellation.Token, exception.CancellationToken); + } + private sealed class Int32Codec : IMemoryCodec { - public bool TryRead(IMemoryReadContext context, Address address, out int value) + public bool TryRead(IMemoryReadContext context, Address address, out int value, out CheatEngineFailure failure) { + failure = default; value = default; return false; } - public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + public bool TryWrite(IMemoryWriteContext context, Address address, in int value, out CheatEngineFailure failure) { + failure = default; return false; } } @@ -346,6 +352,7 @@ internal object? LastCustomWriteValue public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { LastPrimitiveReadAddress = address; LastPrimitiveReadType = typeof(T); @@ -355,13 +362,16 @@ public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T va } public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + where T : unmanaged { - _ = TryReadPrimitive(address, out T? value, out _, cancellationToken); - return value!; + ThrowIfCancelled("Memory.ReadPrimitive", cancellationToken); + _ = TryReadPrimitive(address, out T value, out _, cancellationToken); + return value; } public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { LastPrimitiveWriteAddress = address; LastPrimitiveWriteType = typeof(T); @@ -371,6 +381,7 @@ public bool TryWritePrimitive(Address address, T value, out CheatEngineFailur } public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + where T : unmanaged { _ = TryWritePrimitive(address, value, out _, cancellationToken); } @@ -378,18 +389,19 @@ public void WritePrimitive(Address address, T value, CancellationToken cancel public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, out ImmutableArray values, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { LastPrimitiveBatchReadCount = request.Addresses.Length; T[] result = new T[request.Addresses.Length]; for (int index = 0; index < result.Length; index++) { - if (!TryReadPrimitive(request.Addresses[index], out T? value, out failure, cancellationToken)) + if (!TryReadPrimitive(request.Addresses[index], out T value, out failure, cancellationToken)) { values = []; return false; } - result[index] = value!; + result[index] = value; } values = ImmutableArray.Create(result); @@ -399,13 +411,16 @@ public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, CancellationToken cancellationToken = default) + where T : unmanaged { + ThrowIfCancelled("Memory.ReadPrimitiveBatch", cancellationToken); _ = TryReadPrimitiveBatch(request, out ImmutableArray values, out _, cancellationToken); return values; } public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where T : unmanaged { for (int index = 0; index < request.Values.Length; index++) { @@ -422,10 +437,28 @@ public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest reques public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, CancellationToken cancellationToken = default) + where T : unmanaged { _ = TryWritePrimitiveBatch(request, out _, cancellationToken); } + public MemoryPrimitiveBatchReadOutcome ReadPrimitiveBatchDetailed( + MemoryPrimitiveBatchReadRequest request, CancellationToken cancellationToken = default) + where T : unmanaged + { + _ = TryReadPrimitiveBatch(request, out ImmutableArray values, out _, cancellationToken); + return new MemoryPrimitiveBatchReadOutcome(values.Length, values.AsSpan(), null, null); + } + + public MemoryPrimitiveBatchWriteOutcome WritePrimitiveBatchDetailed( + MemoryPrimitiveBatchWriteRequest request, CancellationToken cancellationToken = default) + where T : unmanaged + { + _ = TryWritePrimitiveBatch(request, out _, cancellationToken); + return new MemoryPrimitiveBatchWriteOutcome(request.Values.Length, request.Values.Length, null, null, + MemoryBatchWriteEffectState.Completed); + } + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -442,6 +475,13 @@ public ImmutableArray ReadBytes(MemoryBytesReadRequest request, return bytes; } + public MemoryBytesReadOutcome ReadBytesDetailed(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + _ = TryReadBytes(request, out ImmutableArray bytes, out _, cancellationToken); + return new MemoryBytesReadOutcome(bytes.Length, bytes, null); + } + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -492,6 +532,7 @@ public bool TryResolvePointerChain(PointerChainRequest request, out Address addr public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) { + ThrowIfCancelled("Memory.ResolvePointerChain", cancellationToken); _ = TryResolvePointerChain(request, out Address address, out _, cancellationToken); return address; } @@ -526,5 +567,16 @@ public void Write(MemoryWriteRequest request, CancellationToken cancellati { _ = TryWrite(request, out _, cancellationToken); } + + /// Reports a cancellation observed before dispatch as Core does: through the failure's Throw. + private static void ThrowIfCancelled(string operation, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, + "The operation was cancelled before Cheat Engine work began.", null, + CheatEngineHostEffect.NotStarted).Throw(cancellationToken); + } + } } } diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs index 0ac81bc..f837046 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs @@ -26,12 +26,12 @@ public void DefaultBatchBuilderRejectsEveryTerminalBeforeBuildingOrDispatchingAR }); foreach (InvalidOperationException exception in new[] - { - readException, tryReadException, writeException, tryWriteException - }) + { + readException, tryReadException, writeException, tryWriteException + }) { - Assert.Contains("Memory.Batch(memory)", exception.Message); Assert.Contains("memory.Batch()", exception.Message); + Assert.DoesNotContain("Memory.Batch(memory)", exception.Message); } } } diff --git a/tests/CheatEngine.Client.Fluent.Tests/README.md b/tests/CheatEngine.Client.Fluent.Tests/README.md index f9d7e76..522225b 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/README.md +++ b/tests/CheatEngine.Client.Fluent.Tests/README.md @@ -15,7 +15,20 @@ the Client facade. The tests prove builder immutability, correct forwarding, invalid-argument rejection, and the bounded semantics of `FirstOrNone`, `RequireSingle`, and `Take`. This keeps ergonomic calls such as `client.Patterns.Aob(...).Take(...)` -predictable without making a live scan part of a unit test. +predictable without making a live scan part of a unit test. `FluentSurfaceTests` pins the surface itself: each domain +has one entry point bound to its service (`IPatternScanner.Aob`, `IMemoryClient.At` and `IMemoryClient.Batch`), a +builder declares no constructor (only a struct's implicit parameterless one), factory or rebinding method, the seven +builders are plain `readonly struct` values that declare no equality members or `ToString`, every operation of a +`default` builder throws the `InvalidOperationException` its XML documentation declares, and every terminal documents +the Client exceptions it can raise. + +The AOB terminals read `IPatternScanner.ScanDetailed`, so the test scanner publishes a detailed outcome whose metrics +match its copied result. The tests prove that `null`, `NotFound` and an empty `Take` result come only from a factual +zero (every row Cheat Engine returned was read), that an unread row or a `nil` result stays `IndeterminateHostResult`, +and that `RequireSingle` reports `AmbiguousMatch` only for a second copied match. The test scanner is a model of Core; +`Composition/FluentAobTerminalCompositionTests` in `CheatEngine.Client.Core.Tests` runs the same terminals against +Core's real `PatternScanner` on the bounded, managed-filter and unscoped routes. The memory tests prove that each +terminal passes the caller's token unchanged, so a cancelled token surfaces as an `OperationCanceledException`. ## Run diff --git a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs index 4b52096..19b20ef 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs @@ -1,24 +1,7 @@ using System.Collections.Immutable; -using CheatEngine.Client.Allocations; -using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; -using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; -using CheatEngine.Client.Inspection; -using CheatEngine.Client.Lua; -using CheatEngine.Client.Memory; -using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Results; -using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; -using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; -using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; @@ -26,33 +9,40 @@ namespace CheatEngine.Client.Fluent.Tests.Scanning; public sealed class AobFluentBuilderTests { + private static readonly ScanProtectionFilter ExecutableCode = new(ScanProtectionRequirement.Required, + ScanProtectionRequirement.Excluded, ScanProtectionRequirement.Excluded); + [Fact] public void ConfigurationMethodsReturnNewBuilderWithoutChangingTheOriginal() { FakePatternScanner scanner = new(); AobScanBuilder original = scanner.Aob("48 8B ?? 89"); - AobScanBuilder configured = original.InModule("game.exe").InRange(0x400000, 0x4FFFFF).Executable(); + AobScanBuilder configured = original.InModule("game.exe").InRange(0x400000, 0x4FFFFF).Executable() + .AlignedTo(4); Assert.Null(original.Module); Assert.Null(original.Range); - Assert.Null(original.Options.ProtectionFlags); + Assert.True(original.Protection.IsUnspecified); + Assert.Equal(ScanAlignment.None, original.Alignment); Assert.Equal("game.exe", configured.Module!.Value.Value); Assert.Equal(new AobScanRange(0x400000, 0x4FFFFF), configured.Range); - Assert.Equal("+X-C-W", configured.Options.ProtectionFlags); + Assert.Equal(ExecutableCode, configured.Protection); + Assert.Equal(ScanAlignment.AlignedTo(4), configured.Alignment); Assert.Equal("48 8B ?? 89", configured.Pattern.Value); } [Fact] - public void ReadableExecutableRemainsACompatibleAliasForExecutable() + public void TheProtectionPresetsNameExecutableCodeAndWritableData() { FakePatternScanner scanner = new(); AobScanBuilder executable = scanner.Aob("90").Executable(); - AobScanBuilder readableExecutable = scanner.Aob("90").ReadableExecutable(); + AobScanBuilder writable = scanner.Aob("90").Executable().Writable(); - Assert.Equal("+X-C-W", executable.Options.ProtectionFlags); - Assert.Equal(executable.Options, readableExecutable.Options); + Assert.Equal(ExecutableCode, executable.Protection); + Assert.Equal(new ScanProtectionFilter(ScanProtectionRequirement.Unspecified, ScanProtectionRequirement.Excluded, + ScanProtectionRequirement.Required), writable.Protection); } [Fact] @@ -76,55 +66,55 @@ public void FluentFiltersNormalizeAndForwardProtectionAlignmentAndRange() { Address expected = 0x401000; FakePatternScanner scanner = new(new AobScanResult([expected], false)); + ScanProtectionFilter protection = new(ScanProtectionRequirement.Any, ScanProtectionRequirement.Excluded, + ScanProtectionRequirement.Required); Address actual = scanner.Aob("90") - .WithProtectionFlags("-w+x-c") - .WithAlignment(FastScanMethod.LastDigits, "f0") + .WithProtection(protection) + .LastDigits("f0") .InRange(0x400000, 0x4FFFFF) .RequireSingle() .Execute(TestContext.Current.CancellationToken); Assert.Equal(expected, actual); AobScanRequest request = Assert.IsType(scanner.LastRequest); - Assert.Equal("+X-C-W", request.Options.ProtectionFlags); - Assert.Equal(FastScanMethod.LastDigits, request.Options.AlignmentMethod); - Assert.Equal("F0", request.Options.AlignmentParameter); + Assert.Equal(protection, request.Protection); + Assert.Equal(ScanAlignmentMode.LastDigits, request.Alignment.Mode); + Assert.Equal("F0", request.Alignment.Digits); Assert.Equal(new AobScanRange(0x400000, 0x4FFFFF), request.Range); } [Theory] - [InlineData("+X+X")] - [InlineData("X")] - public void WithProtectionFlagsRejectsMalformedExpressionsBeforeTerminalSelection(string protection) + [InlineData("")] + [InlineData("0xF0")] + public void LastDigitsRejectsMalformedDigitsBeforeTerminalSelection(string digits) { FakePatternScanner scanner = new(); - Assert.Throws(() => scanner.Aob("90").WithProtectionFlags(protection)); + Assert.Throws(() => scanner.Aob("90").LastDigits(digits)); Assert.Null(scanner.LastRequest); } [Fact] - public void WithAlignmentRejectsAnInvalidDivisorBeforeTerminalSelection() + public void AlignedToRejectsAnInvalidDivisorBeforeTerminalSelection() { FakePatternScanner scanner = new(); - Assert.Throws(() => scanner.Aob("90").WithAlignment(FastScanMethod.Aligned, "0")); + Assert.Throws(() => scanner.Aob("90").AlignedTo(0)); Assert.Null(scanner.LastRequest); } [Fact] - public void RequireSingleReportsAmbiguousMatchWhenTheBoundedResultIsTruncated() + public void TheAlignmentShortcutsSetWhatWithAlignmentSets() { - Address expected = 0x401000; - FakePatternScanner scanner = new(new AobScanResult([expected], true)); - - bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( - out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + FakePatternScanner scanner = new(); + AobScanBuilder builder = scanner.Aob("90").AlignedTo(8); - Assert.False(succeeded); - Assert.Equal(default, address); - Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); - Assert.Equal("Aob.RequireSingle", failure.Operation); + Assert.Equal(builder.WithAlignment(ScanAlignment.AlignedTo(4)).Alignment, builder.AlignedTo(4).Alignment); + Assert.Equal(builder.WithAlignment(ScanAlignment.LastDigits("f0")).Alignment, + builder.LastDigits("f0").Alignment); + Assert.Equal(ScanAlignment.None, builder.WithAlignment(ScanAlignment.None).Alignment); + Assert.Equal(ScanAlignment.AlignedTo(8), builder.Alignment); } [Fact] @@ -154,7 +144,7 @@ public void FirstOrNoneRejectsAHostResponseBeyondItsOneMatchLimit() Assert.False(succeeded); Assert.Null(address); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Aob.FirstOrNone", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); } [Fact] @@ -168,7 +158,7 @@ public void RequireSingleRejectsAHostResponseBeyondItsTwoMatchLimit() Assert.False(succeeded); Assert.Equal(default, address); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Aob.RequireSingle", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); } [Fact] @@ -184,7 +174,7 @@ public void TakeRejectsAHostResponseBeyondTheRequestedLimit() Assert.False(succeeded); Assert.Equal(default, result); Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); - Assert.Equal("Aob.Take", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); AobScanRequest? request = scanner.LastRequest; Assert.NotNull(request); Assert.Equal(1, request.Value.MaximumResults); @@ -315,7 +305,7 @@ public void RequireSingleReportsNotFoundForNoMatches() Assert.False(succeeded); Assert.Equal(default, address); Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); - Assert.Equal("Aob.RequireSingle", failure.Operation); + Assert.Equal("Patterns.Scan", failure.Operation); } [Fact] @@ -344,7 +334,7 @@ public void RequireSingleExecuteThrowsWhenNoMatchExists() scanner.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.NotFound, exception.Failure.Kind); - Assert.Equal("Aob.RequireSingle", exception.Failure.Operation); + Assert.Equal("Patterns.Scan", exception.Failure.Operation); } [Fact] @@ -369,38 +359,355 @@ public void DefaultSingleMatchBuilderRejectsExecution() } [Fact] - public void ClientAobExtensionUsesTheClientPatternScanner() + public void TheParsedPatternEntryPointForwardsTheNormalizedPatternUnchanged() { Address expected = 0x401000; FakePatternScanner scanner = new(new AobScanResult([expected], false)); - ICheatEngineClient client = new FakeCheatEngineClient(scanner); + Assert.True(AobPattern.TryParse("48 8b ?? 89", out AobPattern pattern)); - Address actual = client.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken); + AobScanBuilder builder = scanner.Aob(pattern); + Address actual = builder.RequireSingle().Execute(TestContext.Current.CancellationToken); + Assert.Equal(pattern, builder.Pattern); Assert.Equal(expected, actual); - Assert.Equal(2, Assert.IsType(scanner.LastRequest).MaximumResults); + Assert.Equal("48 8B ?? 89", Assert.IsType(scanner.LastRequest).Pattern.Value); + } + + [Fact] + public void TheAobEntryPointsRejectANullScannerAndAnEmptyPattern() + { + IPatternScanner scanner = null!; + FakePatternScanner bound = new(); + + Assert.Throws(() => scanner.Aob("90")); + Assert.Throws(() => scanner.Aob(new AobPattern("90"))); + Assert.Throws(() => bound.Aob((string) null!)); + Assert.Throws(() => bound.Aob(default(AobPattern))); + Assert.Null(bound.LastRequest); } + /// + /// Core's shape when a third in-request match proves the copy incomplete: two copied matches, truncated. The + /// second copied match makes the result ambiguous. + /// + [Fact] + [Trait("Qualification", "Q28")] + public void RequireSingleReportsAmbiguousWhenASecondHostMatchExists() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000], true)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute(out Address address, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(2, scanner.LastRequest!.Value.MaximumResults); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void AobAmbiguousFalseIsNeverReportedAsNotFound() + { + CheatEngineFailure indeterminate = new(CheatEngineFailureKind.IndeterminateHostResult, "Patterns.Scan", + "Cheat Engine returned no AOB result list: zero matches or a host failure " + + "(indistinguishable on this scan route).", null, CheatEngineHostEffect.Completed); + FakePatternScanner scanner = new(indeterminate); + AobScanBuilder builder = scanner.Aob("90 90").InModule("game.exe"); + + bool firstSucceeded = builder.FirstOrNone().TryExecute(out Address? first, out CheatEngineFailure firstFailure, + TestContext.Current.CancellationToken); + bool singleSucceeded = builder.RequireSingle().TryExecute(out _, out CheatEngineFailure singleFailure, + TestContext.Current.CancellationToken); + bool manySucceeded = builder.Take(3).TryExecute(out AobScanResult many, out CheatEngineFailure manyFailure, + TestContext.Current.CancellationToken); + + Assert.False(firstSucceeded); + Assert.Null(first); + Assert.Equal(indeterminate, firstFailure); + Assert.False(singleSucceeded); + Assert.NotEqual(CheatEngineFailureKind.NotFound, singleFailure.Kind); + Assert.Equal(indeterminate, singleFailure); + Assert.False(manySucceeded); + Assert.Equal(default, many); + Assert.Equal(indeterminate, manyFailure); + CheatEngineOperationException thrown = Assert.Throws(() => + builder.FirstOrNone().Execute(TestContext.Current.CancellationToken)); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, thrown.Failure.Kind); + } + + /// Every throwing terminal raises the cancellation exception with the caller's token. + [Theory] + [InlineData("FirstOrNone")] + [InlineData("Take")] + [InlineData("RequireSingle")] + public void ExecuteThrowsTheCancellationExceptionForACancelledScan(string terminal) + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + CheatEngineFailure cancelled = new(CheatEngineFailureKind.Cancelled, "Patterns.Scan", + "The operation was cancelled before Cheat Engine work began.", null, CheatEngineHostEffect.NotStarted); + FakePatternScanner scanner = new(cancelled); + AobScanBuilder builder = scanner.Aob("90"); + + CheatEngineOperationCanceledException exception = Assert.Throws(() => + Execute(builder, terminal, cancellation.Token)); + + Assert.IsType(exception, exactMatch: false); + Assert.Equal(cancelled, exception.Failure); + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Equal(cancellation.Token, scanner.LastCancellationToken); + } + + /// Every throwing terminal keeps the dedicated activation-expired exception instead of a generic one. + [Theory] + [InlineData("FirstOrNone")] + [InlineData("Take")] + [InlineData("RequireSingle")] + public void ExecuteThrowsTheActivationExpiredExceptionForAnExpiredScanner(string terminal) + { + CheatEngineFailure expired = new(CheatEngineFailureKind.ActivationExpired, "Patterns.Scan", + "The Client activation has ended.", null, CheatEngineHostEffect.NotStarted); + AobScanBuilder builder = new FakePatternScanner(expired).Aob("90"); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + Execute(builder, terminal, TestContext.Current.CancellationToken)); + + Assert.Equal(expired, exception.Failure); + } + + /// + /// A factual zero on each route: the bounded route's empty in-bounds result (with or without rows the Client's + /// own scope check dropped), a managed-filter global list whose rows all lie outside the request, and an empty + /// list that an unscoped global scan does return. Every row was read in each case. + /// + [Theory] + [InlineData(PatternScanScope.HostBoundedRange, 0UL)] + [InlineData(PatternScanScope.HostBoundedRange, 2UL)] + [InlineData(PatternScanScope.GlobalHostScanWithManagedFilter, 3UL)] + [InlineData(PatternScanScope.GlobalHostScan, 0UL)] + public void FirstOrNoneReturnsNullForTheFactualZeroOfEveryRoute(PatternScanScope scope, ulong filteredOut) + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false), scope, filteredOut); + + bool succeeded = scanner.Aob("90").FirstOrNone().TryExecute(out Address? address, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Null(address); + Assert.True(failure.IsDefault); + Assert.Equal(1, scanner.LastRequest!.Value.MaximumResults); + } + + /// + /// An empty copy that left rows unread proves nothing: no terminal turns it into , + /// or an empty result. Core never publishes this shape (it fails a + /// bounded destination filled with rows outside the request itself, and the global route reads every row of an + /// empty copy), so this is the terminals' own guard. + /// + [Theory] + [InlineData("FirstOrNone", PatternScanScope.HostBoundedRange)] + [InlineData("FirstOrNone", PatternScanScope.GlobalHostScanWithManagedFilter)] + [InlineData("RequireSingle", PatternScanScope.GlobalHostScanWithManagedFilter)] + [InlineData("Take", PatternScanScope.GlobalHostScan)] + public void AnEmptyCopyThatLeftRowsUnreadIsIndeterminate(string terminal, PatternScanScope scope) + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false), scope, 4, 5); + AobScanBuilder builder = scanner.Aob("90"); + + bool succeeded; + CheatEngineFailure failure; + switch (terminal) + { + case "FirstOrNone": + succeeded = builder.FirstOrNone().TryExecute(out Address? address, out failure, + TestContext.Current.CancellationToken); + Assert.Null(address); + break; + case "RequireSingle": + succeeded = builder.RequireSingle().TryExecute(out _, out failure, + TestContext.Current.CancellationToken); + break; + default: + succeeded = builder.Take(3).TryExecute(out AobScanResult result, out failure, + TestContext.Current.CancellationToken); + Assert.Equal(default, result); + break; + } + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + CheatEngineOperationException thrown = Assert.Throws(() => + Execute(builder, terminal, TestContext.Current.CancellationToken)); + Assert.Equal(failure, thrown.Failure); + } + + [Fact] + public void FirstOrNoneReturnsTheFirstCopiedMatchEvenWhenRowsRemainUnread() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], true), + PatternScanScope.GlobalHostScanWithManagedFilter, 1, 7); + + Address? actual = scanner.Aob("90").InModule("game.exe").FirstOrNone() + .Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + } + + /// + /// One copied match with unread rows never proves uniqueness, and never proves a second match either. + /// true is the shape Core's bounded route publishes when its full destination (three slots for a limit of + /// two) held one match inside the request and two rows the Client dropped, with a row left unread: one copied + /// match, truncated, not exhaustive. false is a guard: a shape Core never publishes. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RequireSingleNeedsEveryRowReadToProveUniqueness(bool truncated) + { + PatternScanMetrics metrics = truncated + ? new PatternScanMetrics(PatternScanScope.HostBoundedRange, 4, 3, 2, 1, 0, 0, 1, false, + TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1)) + : new PatternScanMetrics(PatternScanScope.GlobalHostScanWithManagedFilter, 3, 1, 0, 1, 0, 0, 2, false, + TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1)); + FakePatternScanner scanner = new(new AobScanResult([0x40FC], truncated), metrics); + + bool succeeded = scanner.Aob("90 90 90 90").InModule("game.exe").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.Completed, failure.HostEffect); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Equal(2, scanner.LastRequest!.Value.MaximumResults); + } + + [Fact] + public void CardinalityFailuresFollowACompletedScan() + { + FakePatternScanner none = new(new AobScanResult(ImmutableArray
.Empty, false)); + FakePatternScanner two = new(new AobScanResult([0x401000, 0x402000], false)); + + _ = none.Aob("90").RequireSingle().TryExecute(out _, out CheatEngineFailure notFound, + TestContext.Current.CancellationToken); + _ = two.Aob("90").RequireSingle().TryExecute(out _, out CheatEngineFailure ambiguous, + TestContext.Current.CancellationToken); + _ = two.Aob("90").FirstOrNone().TryExecute(out _, out CheatEngineFailure beyondLimit, + TestContext.Current.CancellationToken); + + Assert.Equal((CheatEngineFailureKind.NotFound, CheatEngineHostEffect.Completed), + (notFound.Kind, notFound.HostEffect)); + Assert.Equal((CheatEngineFailureKind.AmbiguousMatch, CheatEngineHostEffect.Completed), + (ambiguous.Kind, ambiguous.HostEffect)); + Assert.Equal((CheatEngineFailureKind.InvalidHostResult, CheatEngineHostEffect.Completed), + (beyondLimit.Kind, beyondLimit.HostEffect)); + } + + /// + /// Take follows : any positive limit is forwarded unchanged, + /// and Core's copy cap of 65,535 addresses is reported through . + /// + [Theory] + [InlineData(65_535)] + [InlineData(65_536)] + [InlineData(int.MaxValue)] + public void TakeForwardsAPositiveLimitUnchangedWhateverTheCopyCap(int maximumResults) + { + Address match = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([match], true)); + + AobScanResult result = scanner.Aob("90").Take(maximumResults).Execute(TestContext.Current.CancellationToken); + + Assert.Equal(maximumResults, scanner.LastRequest!.Value.MaximumResults); + Assert.Equal([match], result.Matches); + Assert.True(result.IsTruncated); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void TakeRejectsALimitThatIsNotPositiveBeforeAnyScan(int maximumResults) + { + FakePatternScanner scanner = new(); + + ArgumentOutOfRangeException exception = + Assert.Throws(() => scanner.Aob("90").Take(maximumResults)); + + Assert.Equal("maximumResults", exception.ParamName); + Assert.Null(scanner.LastRequest); + } + + private static void Execute(AobScanBuilder builder, string terminal, CancellationToken cancellationToken) + { + switch (terminal) + { + case "FirstOrNone": + _ = builder.FirstOrNone().Execute(cancellationToken); + break; + case "Take": + _ = builder.Take(2).Execute(cancellationToken); + break; + default: + _ = builder.RequireSingle().Execute(cancellationToken); + break; + } + } + + /// + /// A scanner that answers every request with one detailed outcome whose metrics are consistent with its copied + /// result. Fluent terminals read only. Its default accounting follows + /// the global route (a truncated copy examined one row beyond it); a test of another Core shape passes its + /// metrics, and FluentAobTerminalCompositionTests in Core.Tests runs the terminals against Core's real + /// scanner on every route. + /// private sealed class FakePatternScanner : IPatternScanner { - private readonly CheatEngineFailure _failure; - private readonly AobScanResult _result; - private readonly bool _succeeds; + private readonly PatternScanOutcome _outcome; internal FakePatternScanner() : this(new AobScanResult(ImmutableArray
.Empty, false)) { } - internal FakePatternScanner(AobScanResult result) + /// Creates a scanner whose scan succeeds, with the global route's accounting. + /// The copied result. + /// The route that ran. + /// The rows read that lay outside the request. + /// The rows left unread; any makes the in-request count inexact. + internal FakePatternScanner(AobScanResult result, PatternScanScope scope = PatternScanScope.HostBoundedRange, + ulong filteredOut = 0, ulong unreadRows = 0) + : this(result, GlobalAccounting(result, scope, filteredOut, unreadRows)) { - _result = result; - _succeeds = true; } + /// Creates a scanner whose scan succeeds with explicit metrics. + /// The copied result. + /// The metrics Core publishes with that result. + internal FakePatternScanner(AobScanResult result, PatternScanMetrics metrics) + { + PatternScanRouteReason reason = metrics.Scope switch + { + PatternScanScope.GlobalHostScan => PatternScanRouteReason.UnscopedRequest, + PatternScanScope.HostBoundedRange => PatternScanRouteReason.ScopedRequestOnQualifiedTarget, + _ => PatternScanRouteReason.TargetIdentityNotQualified + }; + PatternScanHostOutcomeKind hostOutcome = metrics.HostResultCount == 0 + ? PatternScanHostOutcomeKind.NoMatches + : PatternScanHostOutcomeKind.Matches; + _outcome = new PatternScanOutcome(result, null, metrics, hostOutcome, reason, false); + } + + /// Creates a scanner whose scan fails. + /// The classified failure. internal FakePatternScanner(CheatEngineFailure failure) { - _failure = failure; + _outcome = new PatternScanOutcome(null, failure, null, PatternScanHostOutcomeKind.Unknown, + PatternScanRouteReason.Unknown, false); } internal AobScanRequest? LastRequest @@ -409,54 +716,40 @@ internal AobScanRequest? LastRequest private set; } + internal CancellationToken LastCancellationToken + { + get; + private set; + } + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - LastRequest = request; - result = _result; - failure = _failure; - return _succeeds; + throw new NotSupportedException("Fluent terminals read ScanDetailed only."); } public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) { - if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) - { - return result; - } + throw new NotSupportedException("Fluent terminals read ScanDetailed only."); + } - failure.Throw(); - return default; + public PatternScanOutcome ScanDetailed(AobScanRequest request, CancellationToken cancellationToken = default) + { + LastRequest = request; + LastCancellationToken = cancellationToken; + return _outcome; } - } - private sealed class FakeCheatEngineClient(IPatternScanner patterns) : ICheatEngineClient - { - public long Epoch => 0; - public CancellationToken Stopping => CancellationToken.None; - public ICheatEngineRuntime Runtime => NotUsed(); - public ICheatEngineDispatcher Dispatcher => NotUsed(); - public IProcessClient Processes => NotUsed(); - public IMemoryClient Memory => NotUsed(); - public IPatternScanner Patterns => patterns; - public IValueScanner Scans => NotUsed(); - public IInspectionClient Inspection => NotUsed(); - public ITableClient Tables => NotUsed(); - public ILuaClient Lua => NotUsed(); - public IAllocationClient Allocations => NotUsed(); - public IAssemblyClient Assembly => NotUsed(); - public IRemoteExecutionClient RemoteExecution => NotUsed(); - public IDebuggerClient Debugger => NotUsed(); - public IHotkeyClient Hotkeys => NotUsed(); - public ITimerClient Timers => NotUsed(); - public ISpeedClient Speed => NotUsed(); - public IHashingClient Hashing => NotUsed(); - public IDbvmClient Dbvm => NotUsed(); - - private static T NotUsed() - where T : class + /// + /// Builds the global route's accounting: every copied and filtered row was examined, a truncated copy also + /// examined the in-request row that proved the cut, and the unread rows follow. + /// + private static PatternScanMetrics GlobalAccounting(AobScanResult result, PatternScanScope scope, + ulong filteredOut, ulong unreadRows) { - throw new InvalidOperationException("This test client only provides a pattern scanner."); + ulong examined = (ulong) result.Matches.Length + filteredOut + (result.IsTruncated ? 1UL : 0UL); + return new PatternScanMetrics(scope, examined + unreadRows, examined, filteredOut, result.Matches.Length, + 0, 0, unreadRows, unreadRows == 0, TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1)); } } } diff --git a/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json index 1aaafd6..dd6a0a9 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json @@ -2,17 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -24,6 +13,35 @@ "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, )", @@ -34,6 +52,12 @@ "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, )", @@ -55,12 +79,12 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", "dependencies": { - "System.IO.Hashing": "10.0.12" + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" } }, "Microsoft.DiaSymReader": { @@ -73,11 +97,6 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -113,11 +132,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -184,20 +198,26 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.fluent": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )" + "CheatEngine.Client.Abstractions": "[1.0.0, )" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" } } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj b/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj index 7673ddb..6aeb7d1 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj @@ -1,7 +1,17 @@ + + + $(NoWarn);CECLIENT5001;CECLIENT5002 + + + + + + + diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs index 7e7c6cf..8486643 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs @@ -1,28 +1,24 @@ +using System.Globalization; using System.Reflection; +using System.Text.Json; using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Extensions.DependencyInjection; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace CheatEngine.Client.Hosting.Tests; @@ -47,15 +43,15 @@ public void ProtectedBaseConstructorAllowsAPublicParameterlessConcretePlugin() } [Fact] - public void GetRequiredClientWithoutAnActiveEnableEpochThrowsLifecycleException() + public void GetRequiredClientWithoutAnActiveEnableEpochThrowsInvalidStateException() { TestPlugin plugin = new(); - CheatEngineClientLifecycleException exception = - Assert.Throws(plugin.GetRequiredClientForTest); + CheatEngineInvalidStateException exception = + Assert.Throws(plugin.GetRequiredClientForTest); Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); - Assert.Equal("GetClient", exception.Failure.Operation); + Assert.Equal("Client.GetRequiredClient", exception.Failure.Operation); Assert.Contains("only while the plugin is enabled", exception.Message, StringComparison.Ordinal); } @@ -71,9 +67,9 @@ public void EnableThenDisableUsesOneScopedClientLifecycleAndReleasesCleanupResou plugin.EnableForTest(); Assert.Same(client, plugin.GetRequiredClientForTest()); - CheatEngineClientLifecycleException duplicateEnable = - Assert.Throws(plugin.EnableForTest); - Assert.Equal("EnableClient", duplicateEnable.Failure.Operation); + CheatEngineInvalidStateException duplicateEnable = + Assert.Throws(plugin.EnableForTest); + Assert.Equal("Client.Activate", duplicateEnable.Failure.Operation); plugin.DisableForTest(); @@ -85,10 +81,11 @@ public void EnableThenDisableUsesOneScopedClientLifecycleAndReleasesCleanupResou events); Assert.Equal(1, cleanup.DrainCount); Assert.Equal(1, cleanup.ScopeDisposeCount); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); } [Fact] + [Trait("Qualification", "Q06")] public void FailedModuleEnableRollsBackAllEnteredModulesAndLeavesThePluginInactive() { List events = []; @@ -108,7 +105,7 @@ public void FailedModuleEnableRollsBackAllEnteredModulesAndLeavesThePluginInacti "module.disabling", "cleanup.drain", "cleanup.exit" ], events); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); plugin.DisableForTest(); @@ -149,10 +146,11 @@ public void DisableRethrowsOneCleanupScopeFailureAfterClosingTheActivation() Assert.Equal("cleanup scope", exception.Message); Assert.Equal(["configure", "client.enabled", "cleanup.enter"], events); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); } [Fact] + [Trait("Qualification", "Q06")] public void ApplicationEnableFailureAndCleanupFailureAreReportedTogetherAfterRollback() { List events = []; @@ -178,6 +176,7 @@ public void ApplicationEnableFailureAndCleanupFailureAreReportedTogetherAfterRol } [Fact] + [Trait("Qualification", "Q43")] public void DisableAggregatesApplicationModuleAndResourceCleanupFailures() { List events = []; @@ -223,12 +222,13 @@ public void ConfigureFailureDoesNotPublishAnActivation() Assert.Equal("configuration", exception.Message); Assert.Equal(1, configureCalls); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); plugin.DisableForTest(); } [Fact] + [Trait("Qualification", "Q06")] public void FailedConstructionCleansEveryAcquiredStageAndAllowsANewEnableEpoch() { List events = []; @@ -259,7 +259,7 @@ public void FailedConstructionCleansEveryAcquiredStageAndAllowsANewEnableEpoch() Assert.Equal( ["configure", "client.resolve", "scope.dispose", "provider.dispose", "configuration.dispose"], events); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); plugin.DisableForTest(); plugin.EnableForTest(); @@ -287,10 +287,28 @@ public void ConfigureFailureRemainsPrimaryWhenConfigurationReleaseAlsoFails() failure => Assert.Equal("configuration", failure.Message), failure => Assert.Equal("configuration dispose", failure.Message)); Assert.Equal(["configuration.dispose"], events); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); } [Fact] + public void InvalidClientOptionsFailTheEnableWithTheirValidationExceptionAfterRollback() + { + List events = []; + FakeClient client = new(51); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => + builder.Configuration["CheatEngineClient:AllowedTableRoots:0"] = "relative-root"); + + OptionsValidationException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Equal(typeof(CheatEngineClientOptions), exception.OptionsType); + Assert.Equal(["configure"], events); + Assert.Equal(0, cleanup.DrainCount); + Assert.Throws(plugin.GetRequiredClientForTest); + } + + [Fact] + [Trait("Qualification", "Q06")] public void FailedModuleEnableRollsBackAndTheSamePluginCanEnableAgain() { List events = []; @@ -311,7 +329,7 @@ public void FailedModuleEnableRollsBackAndTheSamePluginCanEnableAgain() "configure", "module.enabled", "cleanup.enter", "module.disabling", "cleanup.drain", "cleanup.exit" ], events); - Assert.Throws(plugin.GetRequiredClientForTest); + Assert.Throws(plugin.GetRequiredClientForTest); plugin.EnableForTest(); Assert.Same(client, plugin.GetRequiredClientForTest()); @@ -363,6 +381,272 @@ public void FreshActivationProvidersDisposeOwnedModuleDependenciesOnceWhileAlias Assert.Equal(2, state.ProviderSingletonDisposeCount); } + [Fact] + [Trait("Qualification", "Q43")] + [Trait("Qualification", "Q46")] + public void DisableAttemptsEveryStageAndLogsEachFailedStageWithoutExceptionMessages() + { + const string SensitiveModuleText = "module failed at 0x7FFC7A0A0000 reading C:\\Users\\player\\secret.ct"; + const string SensitiveDrainText = "drain failed for symbol game.exe+1234 and Lua 'return readInteger(x)'"; + List events = []; + CapturingLoggerProvider logs = new(); + FakeClient client = new(51); + RecordingCleanup cleanup = new(events, drainFailure: new InvalidOperationException(SensitiveDrainText)); + TestPlugin plugin = CreatePlugin(events, client, cleanup, builder => + { + builder.Services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs)); + builder.Client.AddModule(); + builder.Services.AddSingleton(new SensitiveFailure(SensitiveModuleText)); + }); + plugin.EnableForTest(); + + AggregateException exception = Assert.Throws(plugin.DisableForTest); + + Assert.Equal(2, exception.InnerExceptions.Count); + Assert.Equal(1, cleanup.DrainCount); + Assert.Equal(1, cleanup.ScopeDisposeCount); + Assert.Contains("cleanup.exit", events); + Assert.Collection( + logs.Entries.Where(static entry => entry.EventId == 6), + module => Assert.Equal( + "Cheat Engine Client activation 51 cleanup stage ModuleCallbacks failed with System.InvalidOperationException.", + module.Message), + drain => Assert.Equal( + "Cheat Engine Client activation 51 cleanup stage ClientResources failed with System.InvalidOperationException.", + drain.Message)); + LogEntry completed = Assert.Single(logs.Entries, static entry => entry.EventId == 7); + Assert.Equal("Cheat Engine Client activation 51 attempted 6 cleanup stage(s); 2 failed.", completed.Message); + Assert.Contains(logs.Entries, static entry => entry.EventId == 5); + Assert.All(logs.Entries, static entry => + { + Assert.Null(entry.Exception); + Assert.DoesNotContain("0x7FFC", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("secret", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("game.exe", entry.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("readInteger", entry.Message, StringComparison.Ordinal); + }); + } + + [Fact] + [Trait("Qualification", "Q43")] + public void ThrowingLoggerProviderCannotAbortCleanup() + { + List events = []; + FakeClient client = new(52); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => + { + builder.Services.AddLogging(logging => + logging.SetMinimumLevel(LogLevel.Trace).AddProvider(new ThrowingLoggerProvider())); + builder.Client.AddModule(); + }); + + plugin.EnableForTest(); + Assert.Same(client, plugin.GetRequiredClientForTest()); + plugin.DisableForTest(); + + Assert.Equal( + [ + "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", "module.disabling", + "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.DrainCount); + Assert.Throws(plugin.GetRequiredClientForTest); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void EnableLogsOneIdentificationEventWithoutPaths() + { + List events = []; + CapturingLoggerProvider logs = new(); + FakeClient client = new(53); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, builder => + builder.Services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs))); + + plugin.EnableForTest(); + plugin.DisableForTest(); + + LogEntry identification = Assert.Single(logs.Entries, static entry => entry.EventId == 20); + string message = identification.Message; + Assert.Equal(LogLevel.Information, identification.Level); + Assert.Null(identification.Exception); + Assert.Contains("activation 53 enables " + typeof(TestPlugin).FullName, message, StringComparison.Ordinal); + Assert.Contains("CheatEngine.SDK " + GetConsumedSdkMetadata("Version"), message, StringComparison.Ordinal); + Assert.Contains("NuGet content hash " + GetConsumedSdkMetadata("ContentHashSha512"), message, + StringComparison.Ordinal); + // The test process loads the reviewed package itself, so the identity label, built from versions only, says so. + string loaded = typeof(CheatEngine.SDK.Engine.Runtime.RuntimeInfo).Assembly + .GetCustomAttribute()!.InformationalVersion; + Assert.Contains($"loaded CheatEngine.SDK.Engine {loaded} (the reviewed package), package evidence Satisfied;", + message, StringComparison.Ordinal); + Assert.Contains("supported host profile ce-7.7.0.10621-x64-managed-hostfxr.", message, StringComparison.Ordinal); + Assert.DoesNotMatch(@"[A-Za-z]:\\", message); + Assert.DoesNotContain("\\", message, StringComparison.Ordinal); + Assert.DoesNotContain(".dll", message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(".exe", message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory), message, + StringComparison.OrdinalIgnoreCase); + Assert.True(logs.Entries.ToList().FindIndex(static entry => entry.EventId == 20) < + logs.Entries.ToList().FindIndex(static entry => entry.EventId == 1), + "The identification event must precede the enabled event."); + } + + [Fact] + public void ThrowingLoggerProviderCannotFailEnable() + { + List events = []; + FakeClient client = new(54); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => + { + builder.Services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Trace) + .AddProvider(new ThrowingLoggerProvider(throwFromIsEnabled: true))); + builder.Client.AddModule(); + }); + + plugin.EnableForTest(); + + Assert.Same(client, plugin.GetRequiredClientForTest()); + Assert.Equal(["configure", "module.enabled", "client.enabled"], events); + plugin.DisableForTest(); + Assert.Equal(1, cleanup.DrainCount); + } + + [Fact] + public void ProvidersAddedThroughTheBuilderLoggingReceiveTheActivationEvents() + { + List events = []; + CapturingLoggerProvider logs = new(); + FakeClient client = new(55); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, builder => + builder.Logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs)); + + plugin.EnableForTest(); + plugin.DisableForTest(); + + Assert.Equal([20, 1, 3, 7, 4], logs.Entries.Select(static entry => entry.EventId)); + } + + /// + /// A8 on CheatEngine.SDK 2.0.0: once the SDK detected an external Lua state reset it refuses every Lua admission, + /// the runtime snapshot's included, so the warning reads the SDK's flag through the cleanup bridge, after the + /// Client-owned releases, whose refused Lua admissions can be the ones that detect the reset. The runtime double + /// refuses the snapshot the way the real stack does after a reset, and cleanup never asks it. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CleanupWarnsOnceWhenCheatEngineSdkDetectedAnExternalLuaStateReset(bool detectedByTheReleases) + { + List events = []; + CapturingLoggerProvider logs = new(); + FakeClient client = new(58, new ResetRefusingRuntime(events)); + RecordingCleanup cleanup = new(events) + { + ResetDetected = !detectedByTheReleases, + DrainDetectsReset = detectedByTheReleases + }; + TestPlugin plugin = CreatePlugin(events, client, cleanup, builder => + builder.Logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs)); + + plugin.EnableForTest(); + plugin.DisableForTest(); + + LogEntry warning = Assert.Single(logs.Entries, static entry => entry.EventId == 8); + Assert.Equal(LogLevel.Warning, warning.Level); + Assert.Equal( + "Cheat Engine Client activation 58: Cheat Engine replaced its Lua state outside the plugin's control. Lua " + + "work is refused until the next enable, and Lua-bound resources are not released into the new state.", + warning.Message); + Assert.Equal([20, 1, 3, 8, 7, 4], logs.Entries.Select(static entry => entry.EventId)); + Assert.Equal(1, cleanup.ResetReadCount); + Assert.Equal( + ["configure", "client.enabled", "cleanup.enter", "client.disabling", "cleanup.drain", "cleanup.exit"], + cleanup.EventsBeforeResetRead); + Assert.DoesNotContain("runtime.snapshot", events); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CleanupDoesNotWarnWithoutAReportedExternalLuaStateReset(bool readThrows) + { + List events = []; + CapturingLoggerProvider logs = new(); + FakeClient client = new(59, new ResetRefusingRuntime(events)); + RecordingCleanup cleanup = new(events) + { + ResetReadFailure = readThrows ? new InvalidOperationException("reset read") : null + }; + TestPlugin plugin = CreatePlugin(events, client, cleanup, builder => + builder.Logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs)); + + plugin.EnableForTest(); + plugin.DisableForTest(); + + Assert.DoesNotContain(logs.Entries, static entry => entry.EventId == 8); + Assert.Equal([20, 1, 3, 7, 4], logs.Entries.Select(static entry => entry.EventId)); + Assert.Equal(1, cleanup.ResetReadCount); + Assert.Equal(1, cleanup.DrainCount); + Assert.Equal(1, cleanup.ScopeDisposeCount); + Assert.DoesNotContain("runtime.snapshot", events); + } + + [Fact] + public void ConfigureReceivesTheFolderOfTheConcretePluginAssembly() + { + List events = []; + FakeClient client = new(57); + RecordingCleanup cleanup = new(events); + string? pluginDirectory = null; + TestPlugin plugin = CreatePlugin(events, client, cleanup, + builder => pluginDirectory = builder.PluginDirectory); + + plugin.EnableForTest(); + plugin.DisableForTest(); + + Assert.NotNull(pluginDirectory); + Assert.Equal(Path.GetDirectoryName(typeof(TestPlugin).Assembly.Location), pluginDirectory); + } + + /// + /// appsettings.json is deployed next to the plugin assembly and read from + /// , then bound to the Client options of the activation. + /// + [Fact] + public void AppSettingsFileIsLoadedFromThePluginDirectory() + { + List events = []; + FakeClient client = new(56); + RecordingCleanup cleanup = new(events); + OptionsProbe probe = new(); + TestPlugin plugin = CreatePlugin(events, client, cleanup, builder => + { + builder.Configuration.Sources.Add( + new JsonFileStandInSource(Path.Combine(builder.PluginDirectory, "appsettings.json"))); + builder.Services.AddSingleton(probe); + builder.Client.AddModule(); + }); + + plugin.EnableForTest(); + plugin.DisableForTest(); + + Assert.Equal(4096, probe.MaximumReadBytes); + } + + private static string GetConsumedSdkMetadata(string name) + { + string key = "CheatEngine.Client.ConsumedSdk." + name; + return System.Reflection.Assembly.Load("CheatEngine.Client.Core") + .GetCustomAttributes() + .Single(attribute => attribute.Key == key).Value + ?? throw new InvalidOperationException($"The Core assembly embeds no {key} value."); + } + private static void AddFailingConstructionRegistrations(CheatEnginePluginBuilder builder, List events) { builder.Configuration.Sources.Add(new ThrowingDisposeConfigurationSource(events)); @@ -654,7 +938,7 @@ public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); state.AliasReferencedOwnedDisposable = ReferenceEquals(ownedDisposable, alias.OwnedDisposable); - state.AllowedTableRootCount = options.Value.AllowedTableRoots?.Length ?? 0; + state.AllowedTableRootCount = options.Value.AllowedTableRoots.Count; state.EnabledModuleIds.Add(_id); state.ProviderSingletonIds.Add(providerOwnedSingleton.Id); } @@ -689,6 +973,51 @@ internal int ScopeDisposeCount private set; } + /// Gets or sets the SDK's external Lua state reset fact this cleanup bridge reports. + internal bool ResetDetected + { + get; + set; + } + + /// Gets or sets whether the drain detects the reset, as a refused Lua-bound release does in the SDK. + internal bool DrainDetectsReset + { + get; + init; + } + + /// Gets or sets an exception the reset fact read throws. + internal Exception? ResetReadFailure + { + get; + init; + } + + /// Gets how many times the reset fact was read. + internal int ResetReadCount + { + get; + private set; + } + + /// Gets the lifecycle events recorded before the last read of the reset fact. + internal string[] EventsBeforeResetRead + { + get; + private set; + } = []; + + public bool ExternalLuaStateResetDetected + { + get + { + ResetReadCount++; + EventsBeforeResetRead = [.. events]; + return ResetReadFailure is { } failure ? throw failure : ResetDetected; + } + } + public IDisposable EnterCleanupScope() { events.Add("cleanup.enter"); @@ -708,6 +1037,7 @@ public void DrainOwnedResourcesForDisable() { DrainCount++; events.Add("cleanup.drain"); + ResetDetected |= DrainDetectsReset; if (drainFailure is not null) { throw drainFailure; @@ -760,27 +1090,236 @@ public void Dispose() } } - private sealed class FakeClient(long epoch) : ICheatEngineClient + public sealed record SensitiveFailure(string Message); + + public sealed class SensitiveDisableModule(List events, SensitiveFailure failure) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enabled"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disabling"); + throw new InvalidOperationException(failure.Message); + } + } + + public sealed class OptionsProbe + { + internal int? MaximumReadBytes + { + get; + set; + } + } + + public sealed class OptionsProbeModule(IOptions options, OptionsProbe probe) + : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + probe.MaximumReadBytes = options.Value.MemoryResourceLimits.MaximumReadBytes; + } + + public void OnDisabling(ICheatEngineClient client) + { + } + } + + /// + /// Stands in for the Microsoft.Extensions.Configuration.Json file source that a plugin references and this + /// test project does not: it flattens one JSON file into configuration keys. + /// + private sealed class JsonFileStandInSource(string path) : IConfigurationSource + { + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new JsonFileStandInProvider(path); + } + } + + private sealed class JsonFileStandInProvider(string path) : ConfigurationProvider + { + public override void Load() + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + Flatten(document.RootElement, string.Empty); + } + + private void Flatten(JsonElement element, string key) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + Flatten(property.Value, Child(key, property.Name)); + } + + break; + case JsonValueKind.Array: + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + Flatten(item, Child(key, index.ToString(CultureInfo.InvariantCulture))); + index++; + } + + break; + default: + Data[key] = element.ToString(); + break; + } + } + + private static string Child(string key, string name) + { + return key.Length == 0 ? name : ConfigurationPath.Combine(key, name); + } + } + + private sealed record LogEntry(int EventId, LogLevel Level, string Message, Exception? Exception); + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + private readonly Lock _gate = new(); + private readonly List _entries = []; + + internal IReadOnlyList Entries + { + get + { + lock (_gate) + { + return [.. _entries]; + } + } + } + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(this); + } + + public void Dispose() + { + } + + private void Add(LogEntry entry) + { + lock (_gate) + { + _entries.Add(entry); + } + } + + private sealed class CapturingLogger(CapturingLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + owner.Add(new LogEntry(eventId.Id, logLevel, formatter(state, exception), exception)); + } + } + } + + private sealed class ThrowingLoggerProvider(bool throwFromIsEnabled = false) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) + { + return new ThrowingLogger(throwFromIsEnabled); + } + + public void Dispose() + { + } + + private sealed class ThrowingLogger(bool throwFromIsEnabled) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return throwFromIsEnabled ? throw new InvalidOperationException("The logging filter failed.") : true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + throw new InvalidOperationException("The logging provider failed."); + } + } + } + + /// + /// Refuses the snapshot as the real stack does after CheatEngine.SDK detected an external Lua state reset: the SDK + /// refuses the snapshot's Lua admission and the Client reports the fault as a failure. + /// + private sealed class ResetRefusingRuntime(List events) : ICheatEngineRuntime + { + public long Epoch => 1; + + public bool TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + events.Add("runtime.snapshot"); + snapshot = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.RuntimeChanged, "Runtime.GetSnapshot", + "Cheat Engine replaced its Lua state outside the plugin's control."); + return false; + } + + public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public bool TryGetClientCapability(ClientCapabilityId capability, out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public ClientCapabilityAvailability GetClientCapability(ClientCapabilityId capability, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed class FakeClient(long epoch, ICheatEngineRuntime? runtime = null) : ICheatEngineClient { public long Epoch => epoch; public CancellationToken Stopping => CancellationToken.None; - public ICheatEngineRuntime Runtime => null!; + public ICheatEngineRuntime Runtime => runtime!; public ICheatEngineDispatcher Dispatcher => null!; public IProcessClient Processes => null!; public IMemoryClient Memory => null!; public IPatternScanner Patterns => null!; - public IValueScanner Scans => null!; + public IValueScanner ValueScans => null!; public IInspectionClient Inspection => null!; public ITableClient Tables => null!; public ILuaClient Lua => null!; public IAllocationClient Allocations => null!; +#pragma warning disable CECLIENT5003 // The test client implements the experimental instruction property. public IAssemblyClient Assembly => null!; - public IRemoteExecutionClient RemoteExecution => null!; - public IDebuggerClient Debugger => null!; - public IHotkeyClient Hotkeys => null!; - public ITimerClient Timers => null!; - public ISpeedClient Speed => null!; - public IHashingClient Hashing => null!; - public IDbvmClient Dbvm => null!; +#pragma warning restore CECLIENT5003 } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineHostLogProviderTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineHostLogProviderTests.cs new file mode 100644 index 0000000..81873ee --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineHostLogProviderTests.cs @@ -0,0 +1,253 @@ +using CheatEngine.SDK.Hosting.Diagnostics; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.Hosting.Tests; + +/// The serial collection of the tests that replace CheatEngine.SDK's process-wide host log sink and level. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class HostLogSerialGroup +{ + /// The collection name. + public const string Name = "CheatEngine.SDK host log"; +} + +/// +/// The opt-in host log provider writes through into a fake sink: message templates only by +/// default (Q46), the formatted message on request, the four host levels, and . +/// +[Collection(HostLogSerialGroup.Name)] +public sealed partial class CheatEngineHostLogProviderTests : IDisposable +{ + private const string Category = "CheatEngine.Client.Hosting.Tests.HostLog"; + private const string AddressMarker = "0x7FFC7A0A0000"; + private const string PathMarker = "C:\\Users\\player\\secret.ct"; + + private readonly HostLogLevel _previousLevel; + private readonly IHostLogSink _previousSink; + private readonly RecordingSink _sink = new(); + + public CheatEngineHostLogProviderTests() + { + _previousSink = HostLog.Sink; + _previousLevel = HostLog.MinimumLevel; + HostLog.Sink = _sink; + HostLog.MinimumLevel = HostLogLevel.Trace; + } + + public void Dispose() + { + HostLog.Sink = _previousSink; + HostLog.MinimumLevel = _previousLevel; + } + + [Fact] + public void AddCheatEngineHostLogRegistersOneProviderWithTheOptionsOfTheFirstCall() + { + ServiceCollection services = new(); + + services.AddLogging(logging => logging + .AddCheatEngineHostLog() + .AddCheatEngineHostLog(static options => options.IncludeFormattedMessages = true)); + + ServiceDescriptor descriptor = Assert.Single(services, + static descriptor => descriptor.ServiceType == typeof(ILoggerProvider)); + CheatEngineHostLogProvider provider = Assert.IsType(descriptor.ImplementationInstance); + Assert.False(provider.IncludeFormattedMessages); + } + + [Fact] + public void AddCheatEngineHostLogRejectsMissingArguments() + { + ServiceCollection services = new(); + ILoggingBuilder? logging = null; + services.AddLogging(builder => logging = builder); + + Assert.Throws(static () => ((ILoggingBuilder) null!).AddCheatEngineHostLog()); + Assert.Throws(() => logging!.AddCheatEngineHostLog(null!)); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void EntriesCarryTheMessageTemplateAndNeverTheArgumentValues() + { + using ILoggerFactory factory = CreateFactory(static _ => + { + }); + ILogger logger = factory.CreateLogger(Category); + + LogRead(logger, AddressMarker, PathMarker); + + HostLogEntry entry = Assert.Single(_sink.Entries); + Assert.Equal(HostLogLevel.Information, entry.Level); + Assert.Equal(Category + "[4601]: Read {Address} from {Path}.", entry.Message); + Assert.Null(entry.Exception); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void ExceptionsAreReducedToTheirTypeNameByDefault() + { + using ILoggerFactory factory = CreateFactory(static _ => + { + }); + ILogger logger = factory.CreateLogger(Category); + + LogFailure(logger, new InvalidOperationException("failed at " + AddressMarker), 3); + + HostLogEntry entry = Assert.Single(_sink.Entries); + Assert.Equal(HostLogLevel.Error, entry.Level); + Assert.Equal(Category + "[4602]: Operation failed after {Attempts} attempt(s). " + + "(System.InvalidOperationException)", entry.Message); + Assert.Null(entry.Exception); + Assert.DoesNotContain(AddressMarker, entry.Message, StringComparison.Ordinal); + } + + [Fact] + public void IncludeFormattedMessagesWritesTheFormattedMessageAndTheException() + { + using ILoggerFactory factory = CreateFactory(static options => options.IncludeFormattedMessages = true); + ILogger logger = factory.CreateLogger(Category); + InvalidOperationException failure = new("failed"); + + LogRead(logger, AddressMarker, PathMarker); + LogFailure(logger, failure, 3); + + Assert.Collection( + _sink.Entries, + read => Assert.Equal($"{Category}[4601]: Read {AddressMarker} from {PathMarker}.", read.Message), + failed => + { + Assert.Equal($"{Category}[4602]: Operation failed after 3 attempt(s).", failed.Message); + Assert.Same(failure, failed.Exception); + }); + } + + [Fact] + public void StateWithoutATemplateWritesItsCategoryAndEventOnly() + { + using ILoggerFactory factory = CreateFactory(static _ => + { + }); + ILogger logger = factory.CreateLogger(Category); + + logger.Log(LogLevel.Warning, new EventId(7), "raw state at " + AddressMarker, null, + static (state, _) => state); + + HostLogEntry entry = Assert.Single(_sink.Entries); + Assert.Equal(HostLogLevel.Warning, entry.Level); + Assert.Equal(Category + "[7]", entry.Message); + } + + /// + /// The documented limit of Q46: the template is the caller's text as passed, so a message whose values were + /// written into it before logging, as string interpolation does, reaches the host log with those values. + /// + [Fact] + [Trait("Qualification", "Q46")] + public void AMessageBuiltBeforeLoggingIsWrittenAsItsOwnTemplate() + { + using ILoggerFactory factory = CreateFactory(static _ => + { + }); + ILogger logger = factory.CreateLogger(Category); + +#pragma warning disable CA1848 // The call shape of plugin code that does not use LoggerMessage, which Q46 cannot redact. + logger.LogInformation(new EventId(8), $"Read {AddressMarker}."); +#pragma warning restore CA1848 + + HostLogEntry entry = Assert.Single(_sink.Entries); + Assert.Equal($"{Category}[8]: Read {AddressMarker}.", entry.Message); + } + + [Theory] + [InlineData(LogLevel.Trace, HostLogLevel.Trace)] + [InlineData(LogLevel.Debug, HostLogLevel.Trace)] + [InlineData(LogLevel.Information, HostLogLevel.Information)] + [InlineData(LogLevel.Warning, HostLogLevel.Warning)] + [InlineData(LogLevel.Error, HostLogLevel.Error)] + [InlineData(LogLevel.Critical, HostLogLevel.Error)] + public void EveryLogLevelMapsToAHostLogLevel(LogLevel logLevel, HostLogLevel expected) + { + ILogger logger = new CheatEngineHostLogProvider(false).CreateLogger(Category); + + logger.Log(logLevel, new EventId(1), "state", null, static (state, _) => state); + + Assert.True(logger.IsEnabled(logLevel)); + Assert.Equal(expected, Assert.Single(_sink.Entries).Level); + } + + [Fact] + public void NoneAndUndefinedLevelsAreNeverWritten() + { + ILogger logger = new CheatEngineHostLogProvider(false).CreateLogger(Category); + + logger.Log(LogLevel.None, new EventId(1), "state", null, static (state, _) => state); + logger.Log((LogLevel) 42, new EventId(1), "state", null, static (state, _) => state); + + Assert.False(logger.IsEnabled(LogLevel.None)); + Assert.False(logger.IsEnabled((LogLevel) 42)); + Assert.Empty(_sink.Entries); + } + + [Fact] + public void TheHostLogMinimumLevelDecidesWhatIsWritten() + { + HostLog.MinimumLevel = HostLogLevel.Warning; + ILogger logger = new CheatEngineHostLogProvider(false).CreateLogger(Category); + + logger.Log(LogLevel.Information, new EventId(1), "information", null, static (state, _) => state); + logger.Log(LogLevel.Warning, new EventId(2), "warning", null, static (state, _) => state); + + Assert.False(logger.IsEnabled(LogLevel.Information)); + Assert.True(logger.IsEnabled(LogLevel.Warning)); + Assert.Equal(Category + "[2]", Assert.Single(_sink.Entries).Message); + } + + private static ILoggerFactory CreateFactory(Action configure) + { + return LoggerFactory.Create(logging => logging + .SetMinimumLevel(LogLevel.Trace) + .AddCheatEngineHostLog(configure)); + } + + [LoggerMessage(4601, LogLevel.Information, "Read {Address} from {Path}.")] + private static partial void LogRead(ILogger logger, string address, string path); + + [LoggerMessage(4602, LogLevel.Error, "Operation failed after {Attempts} attempt(s).")] + private static partial void LogFailure(ILogger logger, Exception exception, int attempts); + + private sealed record HostLogEntry(HostLogLevel Level, string Message, Exception? Exception); + + /// Records the entries of this test's category; entries written by other code are ignored. + private sealed class RecordingSink : IHostLogSink + { + private readonly List _entries = []; + private readonly Lock _gate = new(); + + internal IReadOnlyList Entries + { + get + { + lock (_gate) + { + return [.. _entries]; + } + } + } + + public void Write(HostLogLevel level, string message, Exception? exception) + { + if (!message.StartsWith(Category, StringComparison.Ordinal)) + { + return; + } + + lock (_gate) + { + _entries.Add(new HostLogEntry(level, message, exception)); + } + } + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs index 52413ca..0b734b7 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs @@ -1,24 +1,32 @@ +using System.Reflection; + using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Results; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ReflectionAssembly = System.Reflection.Assembly; + namespace CheatEngine.Client.Hosting.Tests; public sealed class CheatEnginePluginBuilderTests { + private static readonly ReflectionAssembly PluginAssembly = typeof(CheatEnginePluginBuilderTests).Assembly; + [Fact] public void BuildServiceProviderValidatesServicesAndBindsTheActivationConfiguration() { - CheatEnginePluginBuilder builder = new(); + CheatEnginePluginBuilder builder = new(PluginAssembly); string allowedRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Hosting.Tests")); builder.Configuration["CheatEngineClient:AllowedTableRoots:0"] = allowedRoot; using ServiceProvider provider = builder.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.Equal([allowedRoot], Assert.IsType(options.AllowedTableRoots)); + Assert.Equal([allowedRoot], options.AllowedTableRoots); Assert.Same(builder.Configuration, provider.GetRequiredService()); Assert.Same(builder.Configuration, provider.GetRequiredService()); } @@ -26,7 +34,7 @@ public void BuildServiceProviderValidatesServicesAndBindsTheActivationConfigurat [Fact] public void BuildServiceProviderIsSingleUse() { - CheatEnginePluginBuilder builder = new(); + CheatEnginePluginBuilder builder = new(PluginAssembly); using ServiceProvider provider = builder.BuildServiceProvider(); InvalidOperationException exception = Assert.Throws(builder.BuildServiceProvider); @@ -34,10 +42,89 @@ public void BuildServiceProviderIsSingleUse() Assert.Contains("only one provider", exception.Message, StringComparison.Ordinal); } + /// Hosting creates the builder and builds the activation provider; application code can do neither. + [Fact] + public void NothingPublicCreatesTheBuilderOrBuildsItsProvider() + { + Type builder = typeof(CheatEnginePluginBuilder); + MethodInfo? build = builder.GetMethod(nameof(CheatEnginePluginBuilder.BuildServiceProvider), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + Assert.Empty(builder.GetConstructors()); + Assert.NotNull(build); + Assert.True(build.IsAssembly); + Assert.Equal(["Client", "Configuration", "Logging", "PluginDirectory", "Services"], + builder.GetProperties().Select(static property => property.Name).Order(StringComparer.Ordinal)); + Assert.All(builder.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | + BindingFlags.DeclaredOnly), + static method => Assert.True(method.IsSpecialName, $"{method.Name} is a public builder method.")); + Assert.All(builder.GetProperties(), static property => Assert.Null(property.SetMethod)); + } + + [Fact] + public void PluginDirectoryIsTheFolderOfThePluginAssembly() + { + CheatEnginePluginBuilder builder = new(PluginAssembly); + + string directory = builder.PluginDirectory; + + Assert.Equal(Path.GetDirectoryName(PluginAssembly.Location), directory); + Assert.True(File.Exists(Path.Combine(directory, Path.GetFileName(PluginAssembly.Location)))); + } + + [Fact] + public void PluginDirectoryRefusesAPluginAssemblyThatWasNotLoadedFromAFile() + { + CheatEnginePluginBuilder builder = new(new LocationlessAssembly()); + + InvalidOperationException exception = + Assert.Throws(() => builder.PluginDirectory); + + Assert.Contains("not loaded from a file", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void LoggingIsTheLoggingViewOfTheActivationServices() + { + CheatEnginePluginBuilder builder = new(PluginAssembly); + + Assert.Same(builder.Services, builder.Logging.Services); + } + + /// + /// The Core diagnostics of an activation log through the activation provider's logger factory: the registered + /// sink writes to a provider added through , under its domain + /// category, and the registration of the Core lifetime takes that same sink. + /// + [Fact] + public void ProvidersAddedThroughLoggingReceiveTheCoreDiagnosticEvents() + { + CapturingLoggerProvider logs = new(); + CheatEnginePluginBuilder builder = new(PluginAssembly); + builder.Logging.SetMinimumLevel(LogLevel.Trace).AddProvider(logs); + ServiceDescriptor coreLifetime = Assert.Single(builder.Services, static descriptor => + descriptor.ServiceType.FullName == "CheatEngine.Client.Core.Infrastructure.CoreLifetime"); + + using ServiceProvider provider = builder.BuildServiceProvider(); + LoggerCoreDiagnostics diagnostics = provider.GetRequiredService(); + diagnostics.PointerWidthMismatchRefused("Memory.ReadPrimitive", 8, 4); + ResolutionRecorder resolutions = new(provider); + + // A unit test has no Cheat Engine plugin context: the registration resolves its diagnostics sink, then the + // capture refuses. + Assert.Throws(() => coreLifetime.ImplementationFactory!(resolutions)); + + (string Category, EventId EventId) entry = Assert.Single(logs.Entries); + Assert.Equal(LoggerCoreDiagnostics.MemoryCategory, entry.Category); + Assert.Equal(1200, entry.EventId.Id); + Assert.Equal([typeof(LoggerCoreDiagnostics)], resolutions.Requested); + Assert.Same(diagnostics, Assert.Single(resolutions.Resolved)); + } + [Fact] public void TwoScopesShareProviderSingletonsButDisposeTheirOwnScopedServices() { - CheatEnginePluginBuilder builder = new(); + CheatEnginePluginBuilder builder = new(PluginAssembly); builder.Services.AddSingleton(); builder.Services.AddScoped(); ProviderOwnedDisposable providerOwned; @@ -69,6 +156,92 @@ public void TwoScopesShareProviderSingletonsButDisposeTheirOwnScopedServices() Assert.Equal(1, providerOwned.DisposeCount); } + /// An assembly loaded from memory: it has no file location. + private sealed class LocationlessAssembly : ReflectionAssembly + { + public override string Location => string.Empty; + } + + /// Resolves from an activation provider and records what a registration factory asks it for. + private sealed class ResolutionRecorder(IServiceProvider services) : IServiceProvider + { + internal List Requested + { + get; + } = []; + + internal List Resolved + { + get; + } = []; + + public object? GetService(Type serviceType) + { + Requested.Add(serviceType); + object? service = services.GetService(serviceType); + if (service is not null) + { + Resolved.Add(service); + } + + return service; + } + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + private readonly List<(string Category, EventId EventId)> _entries = []; + private readonly Lock _gate = new(); + + internal IReadOnlyList<(string Category, EventId EventId)> Entries + { + get + { + lock (_gate) + { + return [.. _entries]; + } + } + } + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(this, categoryName); + } + + public void Dispose() + { + } + + private void Add(string category, EventId eventId) + { + lock (_gate) + { + _entries.Add((category, eventId)); + } + } + + private sealed class CapturingLogger(CapturingLoggerProvider owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + owner.Add(category, eventId); + } + } + } + private sealed class ProviderOwnedDisposable : IDisposable { internal int DisposeCount diff --git a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs index dd3a419..8118cb6 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs @@ -1,21 +1,14 @@ using CheatEngine.Client.Allocations; using CheatEngine.Client.Assembly; -using CheatEngine.Client.Dbvm; -using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; -using CheatEngine.Client.Hashing; -using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using CheatEngine.Client.Processes; -using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; -using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; -using CheatEngine.Client.Timers; namespace CheatEngine.Client.Hosting.Tests; @@ -44,6 +37,7 @@ public void EnableThenCleanupRunsTheApplicationHookAndModulesInTheirSpecifiedOrd } [Fact] + [Trait("Qualification", "Q06")] public void FailedModuleEnableStillCompensatesTheFailingModuleThenEarlierModulesInReverseOrder() { List events = []; @@ -79,6 +73,7 @@ public void FailedApplicationEnableStillRunsItsCompensatingHookBeforeModuleClean } [Fact] + [Trait("Qualification", "Q43")] public void CleanupContinuesAfterFailuresAndIsIdempotent() { List events = []; @@ -160,18 +155,13 @@ private sealed class FakeClient : ICheatEngineClient public IProcessClient Processes => null!; public IMemoryClient Memory => null!; public IPatternScanner Patterns => null!; - public IValueScanner Scans => null!; + public IValueScanner ValueScans => null!; public IInspectionClient Inspection => null!; public ITableClient Tables => null!; public ILuaClient Lua => null!; public IAllocationClient Allocations => null!; +#pragma warning disable CECLIENT5003 // The test client implements the experimental instruction property. public IAssemblyClient Assembly => null!; - public IRemoteExecutionClient RemoteExecution => null!; - public IDebuggerClient Debugger => null!; - public IHotkeyClient Hotkeys => null!; - public ITimerClient Timers => null!; - public ISpeedClient Speed => null!; - public IHashingClient Hashing => null!; - public IDbvmClient Dbvm => null!; +#pragma warning restore CECLIENT5003 } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/README.md b/tests/CheatEngine.Client.Hosting.Tests/README.md index 642649f..3898fea 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/README.md +++ b/tests/CheatEngine.Client.Hosting.Tests/README.md @@ -16,6 +16,26 @@ The suite makes the lifecycle contract executable: a new activation is created p disable, modules observe a deterministic order, and cleanup is performed in reverse order. These tests guard the boundary where a long-lived plugin host meets activation-scoped Client services. +`CheatEngineClientPluginTests` also proves that enable logs one identification event (event 20) without any path +(Q46) and that a logging provider that throws, from `IsEnabled` or `Log`, cannot fail enable or abort cleanup. + +`CheatEnginePluginBuilderTests` proves that application code can neither create the builder nor build its provider, +that `PluginDirectory` is the folder of the plugin assembly (and refuses an assembly without a file location), and that +a provider added through `Logging` receives the Core diagnostic events of the registered sink, which the registration of +the Core lifetime resolves; `CheatEngineClientPluginTests` shows the same provider receiving the Hosting lifecycle +events. The `appsettings.json` of this project is copied next to the test +assembly and read from `PluginDirectory`, the way a plugin reads its own file; a small JSON stand-in replaces +`Microsoft.Extensions.Configuration.Json`, which only the plugin project references. `CheatEngineClientPluginTests` +also proves that cleanup warns once (event 8) when CheatEngine.SDK reports an external Lua state reset, detected during +the activation or by the Client-owned releases, reading the SDK's flag after those releases and never the runtime +snapshot, which the SDK refuses after a reset; without a reset, or when the read fails, nothing changes. + +`CheatEngineHostLogProviderTests` replace CheatEngine.SDK's process-wide `HostLog` sink with a fake one, in a serial +collection: the provider writes message templates and exception type names only by default (Q46), the formatted message +and the exception on request, maps every `LogLevel` to a host level (`Critical` to `Error`, `None` never written), +respects `HostLog.IsEnabled`, and is registered once. They also pin the documented limit of Q46: a message built by +string interpolation is written as its own template, with the values it already carries. + ## Run From the repository root: diff --git a/tests/CheatEngine.Client.Hosting.Tests/appsettings.json b/tests/CheatEngine.Client.Hosting.Tests/appsettings.json new file mode 100644 index 0000000..b0c30de --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/appsettings.json @@ -0,0 +1,7 @@ +{ + "CheatEngineClient": { + "MemoryResourceLimits": { + "MaximumReadBytes": 4096 + } + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json index 60d1d51..58ed7aa 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json @@ -2,17 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -24,6 +13,35 @@ "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, )", @@ -34,6 +52,12 @@ "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, )", @@ -55,12 +79,12 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", "dependencies": { - "System.IO.Hashing": "10.0.12" + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" } }, "Microsoft.DiaSymReader": { @@ -96,11 +120,6 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -136,11 +155,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -207,21 +221,21 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -232,17 +246,17 @@ "cheatengine.client.hosting": { "type": "Project", "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive", diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs index fd38323..774981c 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs @@ -9,6 +9,9 @@ using CheatEngine.Client.Results; using CheatEngine.SDK.Hosting.Bootstrap; +// The fixture retains an experimental allocation lease; the source is compiled standalone by each fixture project. +#pragma warning disable CECLIENT5002 + namespace LivePlugin.Coexistence; /// @@ -17,27 +20,27 @@ namespace LivePlugin.Coexistence; /// internal static class CoexistenceDiagnostics { - private static int s_allowedTableRootCount; - private static long s_epoch; - private static ITargetMemoryLease? s_retainedOwner; - private static uint s_pluginId; - private static ICheatEngineClient? s_activeClient; + private static int _allowedTableRootCount; + private static long _epoch; + private static ITargetMemoryLease? _retainedOwner; + private static uint _pluginId; + private static ICheatEngineClient? _activeClient; internal static void RecordEnabled(uint pluginId, ICheatEngineClient client, int allowedTableRootCount) { ArgumentNullException.ThrowIfNull(client); - Volatile.Write(ref s_allowedTableRootCount, allowedTableRootCount); - Volatile.Write(ref s_epoch, client.Epoch); - Volatile.Write(ref s_pluginId, pluginId); - Interlocked.Exchange(ref s_activeClient, client); + Volatile.Write(ref _allowedTableRootCount, allowedTableRootCount); + Volatile.Write(ref _epoch, client.Epoch); + Volatile.Write(ref _pluginId, pluginId); + Interlocked.Exchange(ref _activeClient, client); } internal static void RecordDisabling() { // A fixture-only Lua callback must not retain an expired Client activation. The normal Client owner registry // remains responsible for target-change and activation cleanup; this releases a still-retained probe lease early. - Interlocked.Exchange(ref s_activeClient, null); - ITargetMemoryLease? owner = Interlocked.Exchange(ref s_retainedOwner, null); + Interlocked.Exchange(ref _activeClient, null); + ITargetMemoryLease? owner = Interlocked.Exchange(ref _retainedOwner, null); owner?.Dispose(); } @@ -56,52 +59,53 @@ internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly) $"SdkHostingAssembly={sdkHostingAssembly.FullName}; SdkHostingMvid={sdkHostingAssembly.ManifestModule.ModuleVersionId}; " + $"PluginALC={Describe(pluginLoadContext)}; ClientHostingALC={Describe(clientHostingLoadContext)}; " + $"SdkHostingALC={Describe(sdkHostingLoadContext)}; SameClientHostingALC={ReferenceEquals(pluginLoadContext, clientHostingLoadContext)}; " + - $"SameSdkHostingALC={ReferenceEquals(pluginLoadContext, sdkHostingLoadContext)}; PluginId={Volatile.Read(ref s_pluginId)}; ClientEpoch={Volatile.Read(ref s_epoch)}; " + - $"AllowedTableRootCount={Volatile.Read(ref s_allowedTableRootCount)}"); + $"SameSdkHostingALC={ReferenceEquals(pluginLoadContext, sdkHostingLoadContext)}; PluginId={Volatile.Read(ref _pluginId)}; ClientEpoch={Volatile.Read(ref _epoch)}; " + + $"AllowedTableRootCount={Volatile.Read(ref _allowedTableRootCount)}"); } /// Refreshes and reports the current target without selecting or otherwise mutating it. internal static string ObserveTarget() { - ICheatEngineClient? client = Volatile.Read(ref s_activeClient); + ICheatEngineClient? client = Volatile.Read(ref _activeClient); if (client is null) { return "Target=Inactive"; } - return client.Processes.TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure) + return client.Processes.TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, client.Stopping) ? string.Create( CultureInfo.InvariantCulture, - $"Target=Selected; ProcessId={snapshot.Id.Value}; SelectionEpoch={snapshot.SelectionEpoch}; Architecture={snapshot.TargetArchitecture}") + $"Target=Selected; ProcessId={snapshot.Id.Value}; SelectionEpoch={snapshot.SelectionEpoch}; Architecture={snapshot.Architecture}") : DescribeFailure("Target", failure); } /// - /// Retains one intentionally tiny allocation only when the exact Client/SDK tuple exposes a qualified allocation - /// owner. The current released Client tuple reports capability unavailable; that outcome is an expected blocker, - /// never a passing retained-owner result. + /// Retains one intentionally tiny allocation through the experimental Client allocations (CECLIENT5002), so that + /// a later target change can show what the Client does with a lease bound to the previous process: the lease ends + /// with a refused release (RefusedTargetChanged, manual recovery required) and never acts on the new target. + /// A refused allocation is reported with its failure; it is a recorded observation, never a passing owner result. /// internal static string RetainOwner() { - ICheatEngineClient? client = Volatile.Read(ref s_activeClient); + ICheatEngineClient? client = Volatile.Read(ref _activeClient); if (client is null) { return "Owner=Inactive"; } - ITargetMemoryLease? prior = Volatile.Read(ref s_retainedOwner); + ITargetMemoryLease? prior = Volatile.Read(ref _retainedOwner); if (prior is not null) { return DescribeOwner("Owner=AlreadyRetained", prior); } - if (!client.Allocations.TryAllocate(new TargetAllocationRequest(16), out ITargetMemoryLease? owner, - out CheatEngineFailure failure)) + if (!client.Allocations.TryAllocate(new AllocationRequest(16), out ITargetMemoryLease? owner, + out CheatEngineFailure failure, client.Stopping)) { return DescribeFailure("Owner", failure); } - ITargetMemoryLease? retainedOwner = Interlocked.CompareExchange(ref s_retainedOwner, owner, null); + ITargetMemoryLease? retainedOwner = Interlocked.CompareExchange(ref _retainedOwner, owner, null); if (retainedOwner is null) { return DescribeOwner("Owner=Retained", owner); @@ -116,14 +120,14 @@ internal static string RetainOwner() /// Reports the retained owner's Client-visible lifecycle state without invoking Cheat Engine. internal static string GetOwnerState() { - ITargetMemoryLease? owner = Volatile.Read(ref s_retainedOwner); + ITargetMemoryLease? owner = Volatile.Read(ref _retainedOwner); return owner is null ? "Owner=None" : DescribeOwner("Owner=Retained", owner); } /// Releases the retained probe owner once, if one exists. internal static string ReleaseOwner() { - ITargetMemoryLease? owner = Interlocked.Exchange(ref s_retainedOwner, null); + ITargetMemoryLease? owner = Interlocked.Exchange(ref _retainedOwner, null); if (owner is null) { return "Owner=None"; @@ -142,9 +146,11 @@ private static string DescribeFailure(string prefix, CheatEngineFailure failure) private static string DescribeOwner(string prefix, ITargetMemoryLease owner) { + LeaseReleaseOutcome? last = owner.LastReleaseOutcome; return string.Create( CultureInfo.InvariantCulture, - $"{prefix}; Released={owner.IsReleased}; SelectionEpoch={owner.SelectionEpoch}; Size={owner.Size}"); + $"{prefix}; Released={owner.IsReleased}; LastRelease={last?.Kind.ToString() ?? "None"}; " + + $"RequiresManualRecovery={owner.RequiresManualRecovery}; SelectionEpoch={owner.SelectionEpoch}; Size={owner.Size}"); } private static string Describe(AssemblyLoadContext? loadContext) diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props index ac5433a..70ec276 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props @@ -17,9 +17,14 @@ false true true - 1.0.0 - - false + + $(CheatEngineSdkVersion) + + $(MSBuildProjectExtensionsPath)coexistence-candidate.packages.lock.json diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs index c2b6e36..fc7dcdd 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs @@ -36,7 +36,7 @@ internal sealed class CoexistencePluginAModule( public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots?.Length ?? 0); + CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots.Count); } /// diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs index c5c131c..09bffab 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs @@ -5,7 +5,7 @@ namespace LivePlugin.Coexistence.PluginA; /// Distinct Lua exports used only by the manual coexistence protocol. internal static partial class CoexistencePluginAFunctions { - private static long s_pingCount; + private static long _pingCount; /// Returns Plugin A's activation-local identity observations. [LuaFunction("cheatengine_client_coexistence_a_identity")] @@ -18,7 +18,7 @@ public static string Identity() [LuaFunction("cheatengine_client_coexistence_a_ping")] public static long Ping() { - return Interlocked.Increment(ref s_pingCount); + return Interlocked.Increment(ref _pingCount); } /// Competes with Plugin Collision for the exact same Lua global name. diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json index 4709618..2cea112 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "CheatEngine.SDK": { "type": "Direct", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", @@ -23,57 +23,8 @@ "Microsoft.Extensions.Primitives": "10.0.12" } }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" - }, - "cheatengine.client.abstractions": { - "type": "Project", - "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" - } - }, - "cheatengine.client.core": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" - } - }, - "cheatengine.client.extensions.dependencyinjection": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", - "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", - "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", - "Microsoft.Extensions.Logging": "[10.0.12, )", - "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", - "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" - } - }, - "cheatengine.client.hosting": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", - "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", - "Microsoft.Extensions.Logging": "[10.0.12, )" - } - }, "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", "dependencies": { @@ -81,8 +32,7 @@ } }, "Microsoft.Extensions.Configuration.Binder": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", "dependencies": { @@ -91,8 +41,7 @@ } }, "Microsoft.Extensions.DependencyInjection": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", "dependencies": { @@ -100,14 +49,12 @@ } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, "Microsoft.Extensions.Logging": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", "dependencies": { @@ -117,17 +64,24 @@ } }, "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", "dependencies": { @@ -139,14 +93,52 @@ } }, "Microsoft.Extensions.Options.DataAnnotations": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", "Microsoft.Extensions.Options": "10.0.12" } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } } } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs index f519f89..64f004d 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs @@ -36,7 +36,7 @@ internal sealed class CoexistencePluginBModule( public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots?.Length ?? 0); + CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots.Count); } /// diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs index d189bb4..0217be9 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs @@ -5,7 +5,7 @@ namespace LivePlugin.Coexistence.PluginB; /// Distinct Lua exports used only by the manual coexistence protocol. internal static partial class CoexistencePluginBFunctions { - private static long s_pingCount; + private static long _pingCount; /// Returns Plugin B's activation-local identity observations. [LuaFunction("cheatengine_client_coexistence_b_identity")] @@ -18,7 +18,7 @@ public static string Identity() [LuaFunction("cheatengine_client_coexistence_b_ping")] public static long Ping() { - return Interlocked.Increment(ref s_pingCount); + return Interlocked.Increment(ref _pingCount); } /// Records the active target observation without selecting a target. diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json index 4709618..2cea112 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "CheatEngine.SDK": { "type": "Direct", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", @@ -23,57 +23,8 @@ "Microsoft.Extensions.Primitives": "10.0.12" } }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" - }, - "cheatengine.client.abstractions": { - "type": "Project", - "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" - } - }, - "cheatengine.client.core": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" - } - }, - "cheatengine.client.extensions.dependencyinjection": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", - "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", - "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", - "Microsoft.Extensions.Logging": "[10.0.12, )", - "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", - "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" - } - }, - "cheatengine.client.hosting": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", - "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", - "Microsoft.Extensions.Logging": "[10.0.12, )" - } - }, "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", "dependencies": { @@ -81,8 +32,7 @@ } }, "Microsoft.Extensions.Configuration.Binder": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", "dependencies": { @@ -91,8 +41,7 @@ } }, "Microsoft.Extensions.DependencyInjection": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", "dependencies": { @@ -100,14 +49,12 @@ } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, "Microsoft.Extensions.Logging": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", "dependencies": { @@ -117,17 +64,24 @@ } }, "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", "dependencies": { @@ -139,14 +93,52 @@ } }, "Microsoft.Extensions.Options.DataAnnotations": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", "Microsoft.Extensions.Options": "10.0.12" } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } } } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs index dd03152..1543404 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs @@ -1,8 +1,11 @@ +using System.Globalization; + using CheatEngine.Client; using CheatEngine.Client.Extensions.DependencyInjection; using CheatEngine.Client.Hosting; using CheatEngine.Client.Lua; using CheatEngine.Client.Modules; +using CheatEngine.Client.Results; using CheatEngine.SDK.Annotations.Plugin; using Microsoft.Extensions.DependencyInjection; @@ -22,30 +25,46 @@ protected override void Configure(CheatEnginePluginBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.Services.AddSingleton(new CoexistencePluginIdentity(Context.PluginId)); - builder.Client - .AddLuaModule() - .AddModule(); + builder.Client.AddModule(); } } -/// Records the activation facts only if collision registration was unexpectedly admitted. +/// +/// Registers the collision Lua module itself, so the expected refusal (Q16) fails the enable with the classification +/// the Client reported, and records the activation facts only if the registration was unexpectedly admitted. +/// +/// +/// The generated module registers through the CheatEngine.SDK registration lease with the RejectExisting +/// policy: the SDK finds Plugin A's global during its preflight and publishes nothing, so the expected failure is +/// OperationRejected with the host effect NotApplied. +/// internal sealed class CoexistencePluginCollisionModule( IOptions options, CoexistencePluginIdentity pluginIdentity) : ICheatEngineClientModule { private readonly CheatEngineClientOptions _options = options.Value; + private ILuaModuleLease? _lease; /// public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots?.Length ?? 0); + if (!client.Lua.TryRegisterModule(new CoexistencePluginCollisionLuaModule(), out ILuaModuleLease? lease, + out CheatEngineFailure failure, client.Stopping)) + { + throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture, + $"Collision=Refused; Kind={failure.Kind}; HostEffect={failure.HostEffect}; Operation={failure.Operation}")); + } + + _lease = lease; + CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots.Count); } /// public void OnDisabling(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); + Interlocked.Exchange(ref _lease, null)?.Dispose(); CoexistenceDiagnostics.RecordDisabling(); } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md index 55b520c..1fb0c4c 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md @@ -4,4 +4,16 @@ This plugin intentionally exports `cheatengine_client_coexistence_a_collision`, not a third positive coexistence participant. In the controlled protocol, enable A first, confirm its marker, then attempt to enable this plugin. The host-visible result must be recorded and Plugin A's marker must remain callable. -Do not load it before A, rename the collision global, or use a manual Lua assignment to repair a failed result. +Its client module registers the generated Lua module itself, through `ILuaClient.TryRegisterModule`, so the expected +enable failure states what the Client reported: +`Collision=Refused; Kind=OperationRejected; HostEffect=NotApplied; Operation=Lua.RegisterModule`. `NotApplied` means the +CheatEngine.SDK registration ran its `RejectExisting` preflight, found A's global and published nothing. Any other +classification, or an enable that succeeds, is a failed observation. + +Qualification scenario Q16 has two host parts. This plugin covers the first one: a collision is refused before any +write, so the established owner keeps its global. The second one, "a third-party replacement survives disable", needs no +extra plugin: an explicit operator script replaces one of Plugin A's globals and A is then disabled (see the subsection +"Third-party replacement survives disable (Q16)" of the Coexistence README one folder up). + +Do not load it before A, rename the collision global, or use a manual Lua assignment to repair a failed result. The +operator script of the second part is a scripted protocol step, not a repair. diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json index 4709618..2cea112 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "CheatEngine.SDK": { "type": "Direct", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", @@ -23,57 +23,8 @@ "Microsoft.Extensions.Primitives": "10.0.12" } }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", - "Microsoft.Extensions.Primitives": "10.0.12" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" - }, - "cheatengine.client.abstractions": { - "type": "Project", - "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" - } - }, - "cheatengine.client.core": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" - } - }, - "cheatengine.client.extensions.dependencyinjection": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", - "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", - "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", - "Microsoft.Extensions.Logging": "[10.0.12, )", - "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", - "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" - } - }, - "cheatengine.client.hosting": { - "type": "Project", - "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", - "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", - "Microsoft.Extensions.Logging": "[10.0.12, )" - } - }, "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", "dependencies": { @@ -81,8 +32,7 @@ } }, "Microsoft.Extensions.Configuration.Binder": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", "dependencies": { @@ -91,8 +41,7 @@ } }, "Microsoft.Extensions.DependencyInjection": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", "dependencies": { @@ -100,14 +49,12 @@ } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, "Microsoft.Extensions.Logging": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", "dependencies": { @@ -117,17 +64,24 @@ } }, "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", "dependencies": { @@ -139,14 +93,52 @@ } }, "Microsoft.Extensions.Options.DataAnnotations": { - "type": "CentralTransitive", - "requested": "[10.0.12, )", + "type": "Transitive", "resolved": "10.0.12", "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", "Microsoft.Extensions.Options": "10.0.12" } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } } } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md index 866c2ad..b2da294 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md @@ -22,10 +22,10 @@ assemblies plus their module-version IDs and load-context facts. Plugin A also exposes an exact collision marker, a target-observation function, and an explicitly opt-in retained-owner probe. Plugin B exposes an independent target-observation function. Both target functions call only `IProcessClient.TryRefresh`: they observe the current selection but never select, create, pause, or mutate a process. -The owner probe is inert until the operator calls it on an authorized disposable target. With the currently released -Client/SDK tuple it returns `CapabilityUnavailable` and no lease; this is a blocker record, not a passing owner test. -When a future qualified Client/SDK tuple provides a real allocation owner, the same probe retains a 16-byte lease so a -subsequent observed target change can prove the old lease was invalidated before anything can act on the new target. +The owner probe is inert until the operator calls it on an authorized disposable target. It then retains a 16-byte +allocation through the experimental Client allocations (`CECLIENT5002`), so that a subsequent observed target change +can show that the old lease ended with a refused release before anything could act on the new target. A refused +allocation is reported with its failure kind; it is an observation to record, not a passing owner test. The fixture never creates a loader policy or process-wide synchronization mechanism. It does not add a target or memory operation automatically. Side-by-side SDK packages, shared Lua/CE state, worker concurrency, target switching, @@ -34,18 +34,21 @@ and retained-owner behavior remain `Specified_Not_Executed` until their exact co ## Package and host boundary The fixture references the changed Client Hosting/source-generator graph so a source build exercises the new Client -contract. It still references the released `CheatEngine.SDK` 1.0.0 package directly, which supplies the SDK entry-point -generator and bridge assets. `CoexistenceSdkPackageVersion` can be overridden per fixture project only when an operator -has an exact candidate SDK package source and tuple to qualify. A non-default version deliberately disables this -fixture's lock-file write path; it is not an invitation to invent or float package versions. +contract. It references the released `CheatEngine.SDK` 2.0.0 package directly, which supplies the SDK entry-point +generator and bridge assets. The default `CoexistenceSdkPackageVersion` follows the repository pin in +`eng/CheatEngineSdk.props`. It can be overridden per fixture project only when an operator has an exact candidate SDK +package source and tuple to qualify. A version other than the pin never rewrites this fixture's committed +`packages.lock.json`: the restore records the candidate closure in `coexistence-candidate.packages.lock.json` under the +project's `obj` folder instead, and lock files stay enabled because disabling them next to a committed lock file fails +the restore (NU1005). It is not an invitation to invent or float package versions. The source fixture is not a replacement for a clean Client package consumer. The package-consumer gate is the C# `PackageConsumptionSmokeTests` suite, which consumes the immutable package directory supplied through `CHEATENGINE_CLIENT_PACKAGE_SOURCE`. The coexistence fixture skips direct-package profile validation because its Client graph is a project reference; that distinction must remain explicit in any live qualification receipt. -The resolved SDK package is not evidence that it contains the later SDK PR #56 source merge, nor is that merge a -published-package or live-host qualification. Before a real run, identify the exact qualified Client/SDK package tuple, +The resolved `CheatEngine.SDK` 2.0.0 package contains the SDK PR #56 source merge, but neither the package nor that +merge is a live-host qualification. Before a real run, identify the exact qualified Client/SDK package tuple, record package and DLL SHA-256 hashes, and keep the complete dependency closure for each plugin in its own directory. Never copy DLLs from one output into another, reuse a bundle directory, or infer the selected SDK version from a filename. @@ -57,10 +60,13 @@ observation, not a portable hosting claim. ## Prepare isolated bundles and a receipt -The current checkout does not include an automated bundle-preparation runner. Do not infer that a live fixture or a -receipt exists from this document. For a future exact SDK package tuple, prepare three disjoint output directories -with an approved harness, record the package and assembly hashes, and retain the complete dependency closure and -host transcript before loading Cheat Engine. +The live qualification runner of `CheatEngine.Client.Tests` prepares the bundles: its session S5 (two load orders, +S5a and S5b) compiles each fixture's sources again in an isolated consumer against the exact packed Client packages and +CheatEngine.SDK 2.0.0 from nuget.org (`PluginBundleBuilder`), deploys each complete closure to its own folder, adds a +plain CheatEngine.SDK 1.x neighbour generated at run time, and drives the steps below that need no plugin toggle; it +records the toggle steps as operator steps until its spike proves that the driver can perform them (see +[Live qualification](../CheatEngine.Client.Tests/README.md#live-qualification)). Do not infer that a receipt exists +from this document. Different requested package versions only make a side-by-side live run eligible. The receipt's assembly identities, package content hashes, full closures, and host transcript must still establish what the exact Cheat Engine loader did. @@ -97,8 +103,11 @@ print(cheatengine_client_coexistence_b_ping()) ``` Re-enable A. Before introducing `PluginCollision`, prove A's owner marker again, then add and attempt to enable the -collision DLL. The generated Client module must refuse to replace the non-`nil` A global. Record the host-visible enable -failure, then prove the established plugin survived untouched: +collision DLL. Its generated Client module registers through the CheatEngine.SDK registration lease with the +`RejectExisting` policy, so the SDK preflight finds A's non-`nil` global and publishes nothing. The expected host-visible +enable failure carries the classification the Client reported: +`Collision=Refused; Kind=OperationRejected; HostEffect=NotApplied; Operation=Lua.RegisterModule`. Record it, then prove +the established plugin survived untouched: ```lua assert(cheatengine_client_coexistence_a_collision() == "CollisionOwner=A") @@ -106,10 +115,57 @@ assert(type(cheatengine_client_coexistence_a_identity) == "function") assert(type(cheatengine_client_coexistence_a_ping) == "function") ``` +### Third-party replacement survives disable (Q16) + +Run this step once the collision attempt is recorded, with A and B enabled. The third party is an explicit operator +script: a deliberate protocol step that replaces one of A's globals after A registered it, not a repair. In the Lua +Engine: + +```lua +cheatengine_client_coexistence_q16_third_party = function() return "ThirdParty=Q16" end +cheatengine_client_coexistence_a_ping = cheatengine_client_coexistence_q16_third_party +``` + +Disable A only, then record: + +```lua +assert(cheatengine_client_coexistence_a_ping == cheatengine_client_coexistence_q16_third_party) +assert(cheatengine_client_coexistence_a_ping() == "ThirdParty=Q16") +assert(cheatengine_client_coexistence_a_identity == nil) +assert(cheatengine_client_coexistence_a_collision == nil) +assert(type(cheatengine_client_coexistence_b_identity) == "function") +print(cheatengine_client_coexistence_b_ping()) +``` + +Expected result: A's disable releases its CheatEngine.SDK registration lease, which removes the globals that still +hold the functions A installed, leaves the third-party value under `cheatengine_client_coexistence_a_ping` in place (the +SDK compares by primitive identity and writes nothing to a replaced global), and does not touch B. A failing assertion +is recorded as `Failed` and is never repaired. + +The operator then removes the third party. While it holds the name, the SDK registration preflight refuses to enable A +again, because the global is defined; that refusal is expected and is not the result of this step: + +```lua +cheatengine_client_coexistence_a_ping = nil +cheatengine_client_coexistence_q16_third_party = nil +``` + +Re-enable A and prove its marker again: + +```lua +assert(cheatengine_client_coexistence_a_collision() == "CollisionOwner=A") +assert(type(cheatengine_client_coexistence_a_ping) == "function") +print(cheatengine_client_coexistence_a_ping()) +``` + +This step observes only the Lua-visible effect on the exact host. It does not observe the `ReplacementCount` of A's +release outcome (`LuaModuleReleaseOutcome`): that count is C1 evidence of the generator EndToEnd tests, not host +evidence. + Disable the failed contender if the host exposes it as enabled, then disable B and finally A, recording every lifecycle result. Stop and retain the failure evidence if a positive plugin cannot load or enable, an expected collision does not -fail, a disabled plugin global remains, or a surviving plugin stops answering. Do not repair a failed observation by -assigning Lua globals manually. +fail, a disabled plugin global remains (other than the operator's third-party value of the Q16 step), or a surviving +plugin stops answering. Do not repair a failed observation by assigning Lua globals manually. ### Target switch and retained-owner extension @@ -123,15 +179,18 @@ print(cheatengine_client_coexistence_a_retain_owner()) print(cheatengine_client_coexistence_a_owner_state()) ``` -If retain returns `Kind=CapabilityUnavailable`, record that the current tuple cannot perform the owner scenario and -stop this extension; it is not a failed live run and it is not a pass. If it returns `Owner=Retained`, switch Cheat -Engine to target B through the controlled host UI, then refresh **both** plugins and verify A's old lease is released -before any further operation: +If retain returns `Owner=Failure`, record its failure kind and stop this extension; it is not a pass. If it returns +`Owner=Retained`, switch Cheat Engine to target B through the controlled host UI, then refresh **both** plugins and +verify that A's old lease has ended before any further operation. Its release is refused, because CheatEngine.SDK +already targets B: the allocation stays in target A and requires manual recovery, and nothing is freed in B: ```lua print(cheatengine_client_coexistence_b_target()) print(cheatengine_client_coexistence_a_target()) -assert(string.find(cheatengine_client_coexistence_a_owner_state(), "Released=true", 1, true)) +local state = cheatengine_client_coexistence_a_owner_state() +assert(string.find(state, "Released=true", 1, true)) +assert(string.find(state, "LastRelease=RefusedTargetChanged", 1, true)) +assert(string.find(state, "RequiresManualRecovery=true", 1, true)) print(cheatengine_client_coexistence_a_release_owner()) ``` @@ -139,6 +198,7 @@ Record both target PIDs, architectures, selection epochs, owner creation/release disable/re-enable results. An unqualified owner, a missing target observation, or an old owner that can act after the switch is a stopped/failing result, never a reason to continue against target B. -No Cheat Engine execution is performed by this repository fixture, runner, or ordinary CI build. A managed Native AOT +No Cheat Engine execution is performed by this repository fixture or by an ordinary CI build. A managed Native AOT probe is publication evidence only; it does not prove that Cheat Engine can load, disable, remove, or unload a Native -AOT plugin. The runner's JSON record is a reproducible build/package-layout receipt, not a `LiveQualified` result. +AOT plugin. No script in this repository produces a receipt for this fixture: its host receipts come from session S5 +of the Client's live qualification runner, on an explicit opt-in, never in CI. diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/CapturingLoggerProviderTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/CapturingLoggerProviderTests.cs new file mode 100644 index 0000000..7275a52 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/CapturingLoggerProviderTests.cs @@ -0,0 +1,76 @@ +using LivePlugin.Qualification.Harness; + +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// The Q46 log sink of the harness keeps what a log reader may see (category, event id, level, message template) and +/// never the formatted message; it counts every event whose formatted text or exception carries scenario data. +/// +/// The provider also feeds the process-wide lifecycle sink, so these tests share its serial collection. +[Collection(QualificationLifecycleSinkGroup.Name)] +public sealed partial class CapturingLoggerProviderTests +{ + [Fact] + public void CapturedEventsKeepTemplatesButNeverFormattedMessages() + { + using CapturingLoggerProvider provider = new(); + ILogger logger = provider.CreateLogger("CheatEngine.Client.Hosting.CheatEngineClientPlugin"); + + ActivationEnabled(logger, 7); + CleanupFailed(logger, "scope"); + + IReadOnlyList events = provider.Events(); + Assert.Equal(2, events.Count); + Assert.Equal(new CapturedLogEvent("CheatEngine.Client.Hosting.CheatEngineClientPlugin", 1, "ActivationEnabled", + LogLevel.Information, "Client activation {Epoch} enabled"), events[0]); + Assert.Equal("Cleanup of {Stage} failed", events[1].Template); + Assert.Equal(42, events[1].EventId); + Assert.Equal(LogLevel.Warning, events[1].Level); + Assert.DoesNotContain(events, static captured => captured.Template.Contains("scope", StringComparison.Ordinal)); + Assert.Equal(0, provider.SensitiveHits); + } + + [Fact] + public void SensitiveHitsCountAddressesAndDeclaredValues() + { + using CapturingLoggerProvider provider = new(); + ILogger logger = provider.CreateLogger("Qualification"); + provider.DeclareSensitive("DE AD BE EF CA FE"); + provider.DeclareSensitive("0x10"); + + ScanFinished(logger, "de ad be ef ca fe"); + Wrote(logger, "0x7FF712345678"); + ScriptApplied(logger, CapturingLoggerProvider.ScriptMarker + "_patch"); + OperationFailed(logger, new InvalidOperationException("failed at 0x10"), "Memory.Write"); + NothingSensitive(logger, 3); + + Assert.Equal(4, provider.SensitiveHits); + Assert.Equal(5, provider.Events().Count); + Assert.All(provider.Events(), static captured => + Assert.DoesNotContain("0x7FF712345678", captured.Template, StringComparison.Ordinal)); + } + + [LoggerMessage(EventId = 1, EventName = "ActivationEnabled", Level = LogLevel.Information, + Message = "Client activation {Epoch} enabled")] + private static partial void ActivationEnabled(ILogger logger, long epoch); + + [LoggerMessage(EventId = 42, EventName = "Cleanup", Level = LogLevel.Warning, Message = "Cleanup of {Stage} failed")] + private static partial void CleanupFailed(ILogger logger, string stage); + + [LoggerMessage(EventId = 100, Level = LogLevel.Information, Message = "Scan of {Pattern} finished")] + private static partial void ScanFinished(ILogger logger, string pattern); + + [LoggerMessage(EventId = 101, Level = LogLevel.Information, Message = "Wrote {Address}")] + private static partial void Wrote(ILogger logger, string address); + + [LoggerMessage(EventId = 102, Level = LogLevel.Information, Message = "Script {Name} applied")] + private static partial void ScriptApplied(ILogger logger, string name); + + [LoggerMessage(EventId = 103, Level = LogLevel.Error, Message = "Operation {Name} failed")] + private static partial void OperationFailed(ILogger logger, Exception exception, string name); + + [LoggerMessage(EventId = 104, Level = LogLevel.Information, Message = "Nothing sensitive in {Count} items")] + private static partial void NothingSensitive(ILogger logger, int count); +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/CheatEngine.Client.LivePlugin.Qualification.Tests.csproj b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/CheatEngine.Client.LivePlugin.Qualification.Tests.csproj new file mode 100644 index 0000000..532112d --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/CheatEngine.Client.LivePlugin.Qualification.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/FakeQualificationEnvironment.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/FakeQualificationEnvironment.cs new file mode 100644 index 0000000..5123eba --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/FakeQualificationEnvironment.cs @@ -0,0 +1,124 @@ +using System.Globalization; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// An in-memory : variables, files, the clock and process images are set by +/// the test, so every gate and fault-switch decision is proven without Cheat Engine or a target process. +/// +internal sealed class FakeQualificationEnvironment : IQualificationEnvironment +{ + internal const int HostProcessId = 4100; + internal const int TargetProcessId = 4200; + internal const string ManifestPath = "fixture/live-probe-authorization.json"; + internal const string TargetSha256 = "34A04005BCAF206EEC990BD9637D9FDB6725E0A0C0D4AEBF003F17F4C956EB5C"; + + internal static readonly DateTimeOffset Now = new(2026, 9, 24, 10, 15, 30, TimeSpan.Zero); + + private readonly Dictionary _variables = new(StringComparer.Ordinal); + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _images = []; + + /// Creates an environment in which the gate allows the run: the exact host, a valid manifest, a live target. + internal FakeQualificationEnvironment() + { + _variables[QualificationAuthorization.AcknowledgementVariable] = QualificationAuthorization.Acknowledgement; + _variables[QualificationAuthorization.ManifestVariable] = ManifestPath; + _images[HostProcessId] = new ProcessImage(QualificationAuthorization.ExactCheatEngineSha256, "Amd64", + QualificationAuthorization.ExactCheatEngineFileVersion); + _images[TargetProcessId] = new ProcessImage(TargetSha256, "Amd64", null); + WriteManifest(); + } + + public DateTimeOffset UtcNow + { + get; + set; + } = Now; + + public int CurrentProcessId => HostProcessId; + + public bool Is64BitProcess + { + get; + set; + } = true; + + public string? GetVariable(string name) + { + return _variables.TryGetValue(name, out string? value) ? value : null; + } + + public bool TryReadFile(string path, out string text) + { + return _files.TryGetValue(path.Replace('\\', '/'), out text!); + } + + public bool TryDescribeProcessImage(int processId, out ProcessImage image) + { + return _images.TryGetValue(processId, out image); + } + + internal void SetVariable(string name, string? value) + { + if (value is null) + { + _variables.Remove(name); + } + else + { + _variables[name] = value; + } + } + + internal void SetFile(string path, string? text) + { + string key = path.Replace('\\', '/'); + if (text is null) + { + _files.Remove(key); + } + else + { + _files[key] = text; + } + } + + internal void SetImage(int processId, ProcessImage? image) + { + if (image is { } value) + { + _images[processId] = value; + } + else + { + _images.Remove(processId); + } + } + + /// Writes the ce77-live-probe-v1 manifest the SDK runner writes, with the given overrides. + internal void WriteManifest( + string acknowledgement = QualificationAuthorization.Acknowledgement, + string hostSha256 = QualificationAuthorization.ExactCheatEngineSha256, + int targetProcessId = TargetProcessId, + string targetSha256 = TargetSha256, + bool disposable = true, + TimeSpan? validFor = null) + { + DateTimeOffset expires = Now + (validFor ?? TimeSpan.FromMinutes(30)); + string manifest = string.Create(CultureInfo.InvariantCulture, $$""" + { + "schema": "{{QualificationAuthorization.ManifestSchema}}", + "acknowledgement": "{{acknowledgement}}", + "hostSha256": "{{hostSha256}}", + "targetProcessId": {{targetProcessId}}, + "targetSha256": "{{targetSha256}}", + "disposable": {{(disposable ? "true" : "false")}}, + "expiresUtc": "{{expires:o}}" + } + """); + SetFile(ManifestPath, manifest); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationAuthorizationTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationAuthorizationTests.cs new file mode 100644 index 0000000..bb54dcf --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationAuthorizationTests.cs @@ -0,0 +1,106 @@ +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// The fail-closed gate of the qualification harness: only the exact phrase, a short-lived ce77-live-probe-v1 +/// manifest of the SDK runner, the pinned Cheat Engine host and the declared, live disposable target authorize a +/// mutating harness function, and only on that target. +/// +public sealed class QualificationAuthorizationTests +{ + [Fact] + public void AuthorizationIsDeniedWithoutTheAcknowledgement() + { + FakeQualificationEnvironment missing = new(); + missing.SetVariable(QualificationAuthorization.AcknowledgementVariable, null); + FakeQualificationEnvironment wrongPhrase = new(); + wrongPhrase.SetVariable(QualificationAuthorization.AcknowledgementVariable, "yes"); + FakeQualificationEnvironment manifestPhrase = new(); + manifestPhrase.WriteManifest(acknowledgement: "I_AUTHORIZE_SOMETHING_ELSE"); + FakeQualificationEnvironment noManifest = new(); + noManifest.SetVariable(QualificationAuthorization.ManifestVariable, null); + + Assert.Equal(AuthorizationDenial.AcknowledgementMissing, QualificationAuthorization.Evaluate(missing).Denial); + Assert.Equal(AuthorizationDenial.AcknowledgementMissing, QualificationAuthorization.Evaluate(wrongPhrase).Denial); + Assert.Equal(AuthorizationDenial.ManifestAcknowledgementMismatch, + QualificationAuthorization.Evaluate(manifestPhrase).Denial); + Assert.Equal(AuthorizationDenial.ManifestMissing, QualificationAuthorization.Evaluate(noManifest).Denial); + } + + [Fact] + public void AuthorizationIsDeniedWhenTheManifestExpired() + { + FakeQualificationEnvironment expired = new(); + expired.WriteManifest(validFor: TimeSpan.FromSeconds(-1)); + FakeQualificationEnvironment tooLong = new(); + tooLong.WriteManifest(validFor: TimeSpan.FromHours(4)); + FakeQualificationEnvironment later = new(); + later.UtcNow = FakeQualificationEnvironment.Now + TimeSpan.FromMinutes(31); + FakeQualificationEnvironment malformed = new(); + malformed.SetFile(FakeQualificationEnvironment.ManifestPath, "{ \"schema\": \"ce77-live-probe-v1\" }"); + FakeQualificationEnvironment notDisposable = new(); + notDisposable.WriteManifest(disposable: false); + + Assert.Equal(AuthorizationDenial.ManifestExpired, QualificationAuthorization.Evaluate(expired).Denial); + Assert.Equal(AuthorizationDenial.ManifestLifetimeTooLong, QualificationAuthorization.Evaluate(tooLong).Denial); + Assert.Equal(AuthorizationDenial.ManifestExpired, QualificationAuthorization.Evaluate(later).Denial); + Assert.Equal(AuthorizationDenial.ManifestInvalid, QualificationAuthorization.Evaluate(malformed).Denial); + Assert.Equal(AuthorizationDenial.TargetNotDisposable, QualificationAuthorization.Evaluate(notDisposable).Denial); + } + + [Fact] + public void AuthorizationIsDeniedWhenTheHostHashDiffers() + { + const string OtherHost = "9D861D651AB9D1DC3C09AE34C8ED5DEE3D1A29B080784C3C48773494C9350230"; + FakeQualificationEnvironment otherBinary = new(); + otherBinary.SetImage(FakeQualificationEnvironment.HostProcessId, new ProcessImage(OtherHost, "Amd64", "7.7.0.10621")); + FakeQualificationEnvironment otherVersion = new(); + otherVersion.SetImage(FakeQualificationEnvironment.HostProcessId, + new ProcessImage(QualificationAuthorization.ExactCheatEngineSha256, "Amd64", "7.6.0.9999")); + FakeQualificationEnvironment manifestPinsAnotherHost = new(); + manifestPinsAnotherHost.WriteManifest(hostSha256: OtherHost); + FakeQualificationEnvironment x86 = new(); + x86.Is64BitProcess = false; + + Assert.Equal(AuthorizationDenial.HostMismatch, QualificationAuthorization.Evaluate(otherBinary).Denial); + Assert.Equal(AuthorizationDenial.HostMismatch, QualificationAuthorization.Evaluate(otherVersion).Denial); + Assert.Equal(AuthorizationDenial.HostMismatch, QualificationAuthorization.Evaluate(manifestPinsAnotherHost).Denial); + Assert.Equal(AuthorizationDenial.NotX64Process, QualificationAuthorization.Evaluate(x86).Denial); + } + + [Fact] + public void AuthorizationIsDeniedForAnotherProcessId() + { + FakeQualificationEnvironment hostAsTarget = new(); + hostAsTarget.WriteManifest(targetProcessId: FakeQualificationEnvironment.HostProcessId, + targetSha256: QualificationAuthorization.ExactCheatEngineSha256); + FakeQualificationEnvironment exitedTarget = new(); + exitedTarget.SetImage(FakeQualificationEnvironment.TargetProcessId, null); + FakeQualificationEnvironment reusedProcessId = new(); + reusedProcessId.SetImage(FakeQualificationEnvironment.TargetProcessId, + new ProcessImage("0000000000000000000000000000000000000000000000000000000000000000", "Amd64", null)); + AuthorizationDecision allowed = QualificationAuthorization.Evaluate(new FakeQualificationEnvironment()); + + Assert.Equal(AuthorizationDenial.TargetIsHost, QualificationAuthorization.Evaluate(hostAsTarget).Denial); + Assert.Equal(AuthorizationDenial.TargetUnavailable, QualificationAuthorization.Evaluate(exitedTarget).Denial); + Assert.Equal(AuthorizationDenial.TargetMismatch, QualificationAuthorization.Evaluate(reusedProcessId).Denial); + Assert.False(allowed.Allows(FakeQualificationEnvironment.TargetProcessId + 1)); + Assert.False(allowed.Allows(0)); + } + + [Fact] + public void AuthorizedManifestAllowsOnlyTheDeclaredTarget() + { + AuthorizationDecision decision = QualificationAuthorization.Evaluate(new FakeQualificationEnvironment()); + + Assert.True(decision.IsAllowed); + Assert.Equal(AuthorizationDenial.None, decision.Denial); + Assert.Equal(FakeQualificationEnvironment.TargetProcessId, decision.TargetProcessId); + Assert.Equal(FakeQualificationEnvironment.TargetSha256, decision.TargetSha256); + Assert.True(decision.Allows(FakeQualificationEnvironment.TargetProcessId)); + Assert.False(decision.Allows(FakeQualificationEnvironment.HostProcessId)); + Assert.False(AuthorizationDecision.Denied(AuthorizationDenial.ManifestMissing) + .Allows(FakeQualificationEnvironment.TargetProcessId)); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationFaultInjectionTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationFaultInjectionTests.cs new file mode 100644 index 0000000..030ba2b --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationFaultInjectionTests.cs @@ -0,0 +1,90 @@ +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// The fault switch of the rollback (Q06) and cleanup (Q43) scenarios: the file the SDK runner writes next to the +/// plugin selects one stage, only when the gate authorized the run; anything else leaves the plugin unfaulted. +/// +public sealed class QualificationFaultInjectionTests +{ + private const string PluginDirectory = "bundle/ClientQualification"; + private const string SwitchPath = PluginDirectory + "/" + QualificationFaultSwitch.FileName; + + [Fact] + public void FaultFileIsIgnoredWhenAuthorizationIsDenied() + { + FakeQualificationEnvironment environment = new(); + environment.SetVariable(QualificationAuthorization.AcknowledgementVariable, null); + environment.SetFile(SwitchPath, Switch("ModuleOnEnabled")); + AuthorizationDecision denied = QualificationAuthorization.Evaluate(environment); + + FaultDecision decision = QualificationFaultSwitch.Read(PluginDirectory, denied, environment); + + Assert.Equal(FaultStage.None, decision.Stage); + Assert.Equal(FaultSwitchReason.IgnoredUnauthorized, decision.Reason); + Assert.False(decision.FaultsEnable); + } + + [Theory] + [InlineData("{ \"schema\": \"cheatengine-client-qualification-fault/v0\", \"throwIn\": \"ModuleOnEnabled\" }")] + [InlineData("{ \"schema\": \"ce77-live-probe-fault-v1\", \"throwIn\": \"OnEnable\" }")] + [InlineData("{ \"schema\": \"ce77-live-probe-fault-v1\", \"throwIn\": \"99\" }")] + [InlineData("{ \"schema\": \"ce77-live-probe-fault-v1\", \"throwIn\": 2 }")] + [InlineData("{ \"schema\": 1, \"throwIn\": \"ModuleOnEnabled\" }")] + [InlineData("not json")] + public void FaultFileWithAnUnknownSchemaIsIgnoredAndReported(string text) + { + FakeQualificationEnvironment environment = new(); + environment.SetFile(SwitchPath, text); + + FaultDecision decision = QualificationFaultSwitch.Read(PluginDirectory, + QualificationAuthorization.Evaluate(environment), environment); + + Assert.Equal(FaultStage.None, decision.Stage); + Assert.Equal(FaultSwitchReason.IgnoredInvalid, decision.Reason); + } + + [Theory] + [InlineData("Configure", false, false, false)] + [InlineData("ModuleOnEnabled", true, false, false)] + [InlineData("ModuleOnDisabling", false, true, false)] + [InlineData("ResourceCleanup", false, false, true)] + [InlineData("ModuleOnDisablingAndResourceCleanup", false, true, true)] + [InlineData("None", false, false, false)] + public void FaultFileSelectsExactlyTheRequestedStage(string stage, bool enable, bool disabling, bool cleanup) + { + FakeQualificationEnvironment environment = new(); + environment.SetFile(SwitchPath, Switch(stage)); + + FaultDecision decision = QualificationFaultSwitch.Read(PluginDirectory, + QualificationAuthorization.Evaluate(environment), environment); + + Assert.Equal(Enum.Parse(stage), decision.Stage); + Assert.Equal(FaultSwitchReason.Selected, decision.Reason); + Assert.Equal(enable, decision.FaultsEnable); + Assert.Equal(disabling, decision.FaultsDisabling); + Assert.Equal(cleanup, decision.FaultsResourceCleanup); + } + + [Fact] + public void AbsentFaultFileMeansNoFault() + { + FakeQualificationEnvironment environment = new(); + + FaultDecision decision = QualificationFaultSwitch.Read(PluginDirectory, + QualificationAuthorization.Evaluate(environment), environment); + FaultDecision noDirectory = QualificationFaultSwitch.Read(null, + QualificationAuthorization.Evaluate(environment), environment); + + Assert.Same(FaultDecision.NoFault, decision); + Assert.Equal(FaultSwitchReason.Absent, decision.Reason); + Assert.Equal(FaultStage.None, noDirectory.Stage); + } + + // The document the SDK runner writes for a scenario with "faultStage" (Invoke-LocalQualification.ps1, stage 7). + private static string Switch(string stage) + { + return "{\"schema\":\"" + QualificationFaultSwitch.Schema + "\",\"throwIn\":\"" + stage + "\"}"; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationInputsTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationInputsTests.cs new file mode 100644 index 0000000..dabd4bb --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationInputsTests.cs @@ -0,0 +1,77 @@ +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// The session inputs of the runner (CECLIENT_QUALIFICATION_*): honored only in an authorized run, the Auto +/// Assembler opt-in only for the exact value 1, and paths only when they are absolute. +/// +public sealed class QualificationInputsTests +{ + private const string TableRoot = @"C:\runs\20260924T101530Z-a1b2\tables"; + private const string LifecycleFile = @"C:\runs\20260924T101530Z-a1b2\sessions\S2\lifecycle.txt"; + + [Fact] + public void InputsAreIgnoredWhenTheGateDeniedTheRun() + { + FakeQualificationEnvironment environment = WithInputs("1", TableRoot, LifecycleFile); + environment.SetVariable(QualificationAuthorization.AcknowledgementVariable, null); + + QualificationInputs inputs = QualificationInputs.Read(QualificationAuthorization.Evaluate(environment), environment); + + Assert.Same(QualificationInputs.None, inputs); + Assert.False(inputs.EnableAutoAssembler); + Assert.Null(inputs.TableRoot); + Assert.Null(inputs.LifecycleFile); + } + + [Fact] + public void AnAuthorizedRunReadsEveryInput() + { + FakeQualificationEnvironment environment = WithInputs("1", TableRoot, LifecycleFile); + + QualificationInputs inputs = QualificationInputs.Read(QualificationAuthorization.Evaluate(environment), environment); + + Assert.True(inputs.EnableAutoAssembler); + Assert.Equal(Path.GetFullPath(TableRoot), inputs.TableRoot); + Assert.Equal(Path.GetFullPath(LifecycleFile), inputs.LifecycleFile); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("true")] + [InlineData("01")] + [InlineData(" 1")] + public void OnlyTheExactValueOneComposesTheAutoAssemblerOptIn(string? value) + { + FakeQualificationEnvironment environment = WithInputs(value, null, null); + + Assert.False(QualificationInputs.Read(QualificationAuthorization.Evaluate(environment), environment) + .EnableAutoAssembler); + } + + [Theory] + [InlineData("tables")] + [InlineData(@"..\tables")] + [InlineData(" ")] + public void RelativeOrBlankPathsAreIgnored(string path) + { + FakeQualificationEnvironment environment = WithInputs(null, path, path); + + QualificationInputs inputs = QualificationInputs.Read(QualificationAuthorization.Evaluate(environment), environment); + + Assert.Null(inputs.TableRoot); + Assert.Null(inputs.LifecycleFile); + } + + private static FakeQualificationEnvironment WithInputs(string? enableAutoAssembler, string? tableRoot, + string? lifecycleFile) + { + FakeQualificationEnvironment environment = new(); + environment.SetVariable(QualificationInputs.EnableAutoAssemblerVariable, enableAutoAssembler); + environment.SetVariable(QualificationInputs.TableRootVariable, tableRoot); + environment.SetVariable(QualificationInputs.LifecycleFileVariable, lifecycleFile); + return environment; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationLifecycleSinkTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationLifecycleSinkTests.cs new file mode 100644 index 0000000..df143f4 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationLifecycleSinkTests.cs @@ -0,0 +1,77 @@ +using LivePlugin.Qualification.Harness; + +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// The serial collection of the tests that touch the process-wide lifecycle sink. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class QualificationLifecycleSinkGroup +{ + /// The collection name. + public const string Name = "Qualification lifecycle sink"; +} + +/// +/// The Q43 lifecycle sink: the lifecycle record and captured log templates reach the runner's file one line each, +/// only when the runner configured a file, never a formatted message, and a failing write never throws. +/// +[Collection(QualificationLifecycleSinkGroup.Name)] +public sealed class QualificationLifecycleSinkTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("qualification-sink-").FullName; + + public void Dispose() + { + QualificationLifecycleSink.Configure(null); + Directory.Delete(_directory, true); + } + + [Fact] + public void NothingIsWrittenWithoutAConfiguredFile() + { + QualificationLifecycleSink.Configure(null); + + QualificationLifecycleSink.Ledger("#1 first.enabled"); + + Assert.False(QualificationLifecycleSink.IsConfigured); + Assert.Empty(Directory.EnumerateFileSystemEntries(_directory)); + } + + [Fact] + public void LedgerEntriesAndLogTemplatesAreAppendedOneLineEach() + { + string path = Path.Combine(_directory, "lifecycle.txt"); + QualificationLifecycleSink.Configure(path); + using CapturingLoggerProvider provider = new(); + + QualificationLifecycleSink.Ledger("#2 fault.disabling.threw InvalidOperationException"); + provider.CreateLogger("CheatEngine.Client.Hosting.CheatEngineClientPlugin").Log(LogLevel.Warning, + new EventId(5, "ActivationCleanupFailed"), "cleanup of 0x7FF712345678 failed", null, + static (_, _) => "formatted 0x7FF712345678"); + QualificationLifecycleSink.Ledger("#2 first\tdisabling\nsplit"); + + string[] lines = File.ReadAllLines(path); + Assert.Contains("ledger\t#2 fault.disabling.threw InvalidOperationException", lines); + Assert.Contains("log\tWarning\tCheatEngine.Client.Hosting.CheatEngineClientPlugin\t5\t", lines); + Assert.Contains("ledger\t#2 first disabling split", lines); + Assert.DoesNotContain(lines, static line => line.Contains("0x7FF712345678", StringComparison.Ordinal)); + } + + [Fact] + public void AFailedWriteIsCountedNeverThrown() + { + QualificationLifecycleSink.Configure(_directory); + int before = QualificationLifecycleSink.FailedWrites; + + QualificationLifecycleSink.Ledger("#3 last.disabling"); + + Assert.Equal(before + 1, QualificationLifecycleSink.FailedWrites); + } + + [Fact] + public void FieldsLoseTheirControlCharacters() + { + Assert.Equal("a b c d", QualificationLifecycleSink.Clean("a\tb\rc\nd")); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationObservationWriterTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationObservationWriterTests.cs new file mode 100644 index 0000000..25520d0 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationObservationWriterTests.cs @@ -0,0 +1,110 @@ +using System.Text.Json; + +using CheatEngine.Client.Results; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// The JSON a harness function returns is bounded and redacted by construction: it names its schema, never carries a +/// local path, a failure message or exception text, and reduces an address list to its count and edges. +/// +public sealed class QualificationObservationWriterTests +{ + [Fact] + public void ObservationJsonHasTheSchemaIdAndNoLocalPath() + { + using QualificationObservation observation = new("status"); + + string json = observation.String("plugin", @"C:\Users\someone\ce\plugins\CheatEngine.Client.LivePlugin.Qualification.dll") + .String("unc", @"\\server\share\bundle") + .String("home", "/home/someone/bundle") + .String("uri", "file:///c:/bundle") + .String("kept", "https://github.com/CheatEngineNet/CheatEngine.Client and %LOCALAPPDATA%") + .Strings("list", [@"D:\wt\bundle", "stage.enabled"]) + .Complete(); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.Equal(QualificationObservation.Schema, root.GetProperty("schema").GetString()); + Assert.Equal("status", root.GetProperty("function").GetString()); + foreach (string name in (string[]) ["plugin", "unc", "home", "uri"]) + { + Assert.Equal(QualificationObservation.RedactedPath, root.GetProperty(name).GetString()); + } + + Assert.Equal("https://github.com/CheatEngineNet/CheatEngine.Client and %LOCALAPPDATA%", + root.GetProperty("kept").GetString()); + Assert.Equal([QualificationObservation.RedactedPath, "stage.enabled"], + root.GetProperty("list").EnumerateArray().Select(static item => item.GetString()!)); + Assert.DoesNotContain("someone", json, StringComparison.Ordinal); + } + + [Fact] + public void ObservationNeverCarriesFailureMessagesOrExceptionText() + { + CheatEngineFailure failure = new(CheatEngineFailureKind.IndeterminateHostResult, "Patterns.Scan", + @"No result list for pattern DE AD BE EF at C:\Users\someone\target.exe", + new InvalidOperationException("secret 0x7FF712345678")); + using QualificationObservation observation = new("aob"); + + string json = observation.Failure("failure", failure).Complete(); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement written = document.RootElement.GetProperty("failure"); + + Assert.Equal("IndeterminateHostResult", written.GetProperty("kind").GetString()); + Assert.Equal("Patterns.Scan", written.GetProperty("operation").GetString()); + Assert.Equal(failure.HostEffect.ToString(), written.GetProperty("hostEffect").GetString()); + Assert.Equal(["kind", "operation", "hostEffect"], written.EnumerateObject().Select(static property => property.Name)); + Assert.DoesNotContain("DE AD BE EF", json, StringComparison.Ordinal); + Assert.DoesNotContain("secret", json, StringComparison.Ordinal); + Assert.DoesNotContain("0x7FF712345678", json, StringComparison.Ordinal); + } + + [Fact] + public void AddressListsAreBoundedToTheFirstAndLastEntries() + { + ulong[] many = [.. Enumerable.Range(0, 20000).Select(static index => 0x1_0000_0000UL + ((ulong) index * 8))]; + ulong[] few = [0x1000, 0x2000, 0x3000]; + ulong[] twelve = [.. Enumerable.Range(1, 12).Select(static index => (ulong) index)]; + using QualificationObservation observation = new("aob"); + + string json = observation.Addresses("many", many).Addresses("few", few).Addresses("twelve", twelve) + .Addresses("none", []).Complete(); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.Equal(20000, root.GetProperty("many").GetProperty("count").GetInt32()); + Assert.Equal(QualificationObservation.AddressListEdge, root.GetProperty("many").GetProperty("first").GetArrayLength()); + Assert.Equal(QualificationObservation.AddressListEdge, root.GetProperty("many").GetProperty("last").GetArrayLength()); + Assert.Equal("0x100000000", root.GetProperty("many").GetProperty("first")[0].GetString()); + Assert.Equal("0x1000270F8", root.GetProperty("many").GetProperty("last")[7].GetString()); + Assert.Equal(["0x1000", "0x2000", "0x3000"], + root.GetProperty("few").GetProperty("first").EnumerateArray().Select(static item => item.GetString()!)); + Assert.Equal(0, root.GetProperty("few").GetProperty("last").GetArrayLength()); + Assert.Equal(["0x9", "0xA", "0xB", "0xC"], + root.GetProperty("twelve").GetProperty("last").EnumerateArray().Select(static item => item.GetString()!)); + Assert.Equal(0, root.GetProperty("none").GetProperty("count").GetInt32()); + Assert.True(json.Length < 2048, "An observation of 20000 addresses stays small."); + } + + [Fact] + public void UnknownFactsAreWrittenAsNullNeverAsADefault() + { + using QualificationObservation observation = new("runtime"); + + string json = observation.OptionalNumber("configuredPointerSizeBytes", null) + .OptionalNumber("knownBytes", 8) + .OptionalBoolean("targetIsAndroid", null) + .OptionalBoolean("differsFromBitness", false) + .Complete(); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.Equal(JsonValueKind.Null, root.GetProperty("configuredPointerSizeBytes").ValueKind); + Assert.Equal(8, root.GetProperty("knownBytes").GetInt32()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("targetIsAndroid").ValueKind); + Assert.Equal(JsonValueKind.False, root.GetProperty("differsFromBitness").ValueKind); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationPluginSourceComplianceTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationPluginSourceComplianceTests.cs new file mode 100644 index 0000000..c87eaa1 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationPluginSourceComplianceTests.cs @@ -0,0 +1,179 @@ +using System.Text.RegularExpressions; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// Lightweight source-scan proof for the two safety properties the harness project and its README assert but that +/// no compiled test previously checked: it reaches Cheat Engine only through the public Client API (ADR-01), and +/// every Lua function the README marks mutating runs through QualificationScenarios.RunMutating before it can +/// touch the live target. The x64 plugin project cannot be referenced from this AnyCPU test module (see the project +/// README), so these tests read its own source files as text instead of compiling against it. +/// +public sealed partial class QualificationPluginSourceComplianceTests +{ + private static readonly string[] ForbiddenTokens = + [ + "LuaState", "LuaFrame", "AcquireOperation", "LuaRuntime", "[LuaGlobal", "DllImport", "LibraryImport", + "CheatEngine.SDK.Lua" + ]; + + [Fact] + public void QualificationPluginUsesOnlyTheClientApiForCheatEngineAccess() + { + List violations = []; + foreach (string file in PluginSourceFiles()) + { + string text = File.ReadAllText(file); + foreach (string token in ForbiddenTokens) + { + if (text.Contains(token, StringComparison.Ordinal)) + { + violations.Add($"{Path.GetFileName(file)} contains '{token}'"); + } + } + } + + Assert.True(violations.Count == 0, + "The qualification plugin must reach Cheat Engine only through the Client API (ADR-01); none of its own " + + $"files may touch the SDK's native Lua stack or interop directly:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + + [Fact] + public void MutatingLuaFunctionsRouteThroughTheAuthorizationGate() + { + Dictionary mutatingByLuaName = ParseMutatingColumn(ReadmeText()); + Dictionary targetByLuaName = ParseLuaFunctionTargets(LuaFunctionsText()); + string scenariosText = ScenariosText(); + + Assert.True(mutatingByLuaName.Count >= 10, + "The README 'Lua functions' table did not parse as expected (found " + + $"{mutatingByLuaName.Count} rows); check the table's markdown shape against the parser in this test."); + + List problems = []; + foreach (KeyValuePair row in mutatingByLuaName) + { + if (!targetByLuaName.TryGetValue(row.Key, out string? target)) + { + problems.Add( + $"{row.Key}(): the README lists it, but no [LuaFunction(\"cheatengine_client_qualification_{row.Key}\")] method delegating to a QualificationScenarios method was found."); + continue; + } + + if (!row.Value) + { + continue; + } + + string? body = MethodBody(scenariosText, target); + if (body is null) + { + problems.Add($"{row.Key}() -> QualificationScenarios.{target}: the method was not found."); + } + else if (!body.Contains("RunMutating(", StringComparison.Ordinal)) + { + problems.Add($"{row.Key}() -> QualificationScenarios.{target}: its body never calls RunMutating."); + } + } + + Assert.True(problems.Count == 0, + "Every Lua function the README's 'Lua functions' table marks mutating must run through " + + $"QualificationScenarios.RunMutating before it can touch the live target:{Environment.NewLine}{string.Join(Environment.NewLine, problems)}"); + } + + private static IEnumerable PluginSourceFiles() + { + string root = PluginProjectDirectory(); + foreach (string file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(root, file); + if (relative.Split(Path.DirectorySeparatorChar).Any(segment => segment is "bin" or "obj")) + { + continue; + } + + yield return file; + } + } + + private static Dictionary ParseMutatingColumn(string readme) + { + Dictionary mutatingByName = []; + foreach (Match match in LuaFunctionTableRow().Matches(readme)) + { + string mutatingText = match.Groups["mutating"].Value.Trim(); + mutatingByName[match.Groups["name"].Value] = !mutatingText.Equals("no", StringComparison.OrdinalIgnoreCase); + } + + return mutatingByName; + } + + private static Dictionary ParseLuaFunctionTargets(string luaFunctionsSource) + { + Dictionary targetByLuaName = []; + foreach (Match match in DelegatingLuaFunction().Matches(luaFunctionsSource)) + { + targetByLuaName[match.Groups["lua"].Value] = match.Groups["target"].Value; + } + + return targetByLuaName; + } + + private static string? MethodBody(string scenariosSource, string methodName) + { + string startMarker = $"internal static string {methodName}("; + int start = scenariosSource.IndexOf(startMarker, StringComparison.Ordinal); + if (start < 0) + { + return null; + } + + int next = scenariosSource.IndexOf("\n\tinternal static string ", start + startMarker.Length, StringComparison.Ordinal); + int end = next >= 0 ? next : scenariosSource.Length; + return scenariosSource[start..end]; + } + + private static string ReadmeText() + { + return File.ReadAllText(Path.Combine(PluginProjectDirectory(), "README.md")); + } + + private static string LuaFunctionsText() + { + return File.ReadAllText(Path.Combine(PluginProjectDirectory(), "QualificationLuaFunctions.cs")); + } + + /// Every file of the partial QualificationScenarios class, concatenated in ordinal order. + private static string ScenariosText() + { + return string.Concat(Directory.EnumerateFiles(PluginProjectDirectory(), "QualificationScenarios*.cs") + .Order(StringComparer.Ordinal) + .Select(File.ReadAllText)); + } + + private static string PluginProjectDirectory() + { + return Path.Combine(RepositoryRootPath(), "tests", "CheatEngine.Client.LivePlugin.Qualification"); + } + + private static string RepositoryRootPath() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "CheatEngine.Client.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"'CheatEngine.Client.slnx' was not found above '{AppContext.BaseDirectory}': this test expects to run from the repository's artifacts directory."); + } + + [GeneratedRegex(@"^\|\s*`(?[A-Za-z_][A-Za-z0-9_]*)\([^`]*\)`\s*\|\s*(?[^|]+)\|", + RegexOptions.Multiline)] + private static partial Regex LuaFunctionTableRow(); + + [GeneratedRegex("\\[LuaFunction\\(\"cheatengine_client_qualification_(?[a-z0-9_]+)\"\\)\\][^{]*\\{\\s*" + + "return\\s+QualificationScenarios\\.(?[A-Za-z0-9_]+)\\(")] + private static partial Regex DelegatingLuaFunction(); +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationWriteGuardTests.cs b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationWriteGuardTests.cs new file mode 100644 index 0000000..7e199ed --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/QualificationWriteGuardTests.cs @@ -0,0 +1,100 @@ +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.LivePlugin.Qualification.Tests; + +/// +/// The write guard of every mutating harness function: a write reaches the Client memory API only for the authorized +/// target the Client observes, and only inside a region declared for that process. +/// +public sealed class QualificationWriteGuardTests +{ + private const ulong ScratchBase = 0x0000_01E2_8800_0000; + private const int ScratchLength = 4096; + + private static AuthorizationDecision Allowed => + QualificationAuthorization.Evaluate(new FakeQualificationEnvironment()); + + private static TargetDeclaration Declaration(int processId = FakeQualificationEnvironment.TargetProcessId) + { + return new TargetDeclaration(processId, [new WritableRegion("scratch", ScratchBase, ScratchLength)]); + } + + [Fact] + public void WritesOutsideADeclaredRegionAreRefused() + { + const int Target = FakeQualificationEnvironment.TargetProcessId; + + Assert.Equal(WriteRefusal.None, QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), ScratchBase, 8)); + Assert.Equal(WriteRefusal.None, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), ScratchBase + ScratchLength - 8, 8)); + Assert.Equal(WriteRefusal.OutsideDeclaredRegion, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), ScratchBase + ScratchLength - 4, 8)); + Assert.Equal(WriteRefusal.OutsideDeclaredRegion, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), ScratchBase - 1, 2)); + Assert.Equal(WriteRefusal.OutsideDeclaredRegion, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), 0x10, 4)); + Assert.Equal(WriteRefusal.OutsideDeclaredRegion, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), ScratchBase, 0)); + Assert.Equal(WriteRefusal.OutsideDeclaredRegion, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(), ulong.MaxValue - 2, 8)); + Assert.True(QualificationWriteGuard.IsNeverMapped(0x10)); + Assert.False(QualificationWriteGuard.IsNeverMapped(ScratchBase)); + } + + [Fact] + public void WritesAreRefusedWhenTheDeclarationOrTheClientTargetNamesAnotherProcess() + { + const int Target = FakeQualificationEnvironment.TargetProcessId; + FakeQualificationEnvironment unauthorized = new(); + unauthorized.SetVariable(QualificationAuthorization.AcknowledgementVariable, null); + + Assert.Equal(WriteRefusal.DeclarationForAnotherProcess, + QualificationWriteGuard.Evaluate(Allowed, Target, Declaration(Target + 1), ScratchBase, 8)); + Assert.Equal(WriteRefusal.TargetNotAuthorized, + QualificationWriteGuard.Evaluate(Allowed, Target + 1, Declaration(Target + 1), ScratchBase, 8)); + Assert.Equal(WriteRefusal.TargetNotAuthorized, + QualificationWriteGuard.Evaluate(Allowed, 0, Declaration(), ScratchBase, 8)); + Assert.Equal(WriteRefusal.NotAuthorized, + QualificationWriteGuard.Evaluate(QualificationAuthorization.Evaluate(unauthorized), Target, Declaration(), + ScratchBase, 8)); + } + + [Fact] + public void MutationScopesNeverRelaxTheGateAndNameTheirOwnTarget() + { + const int Target = FakeQualificationEnvironment.TargetProcessId; + FakeQualificationEnvironment unauthorized = new(); + unauthorized.SetVariable(QualificationAuthorization.AcknowledgementVariable, null); + AuthorizationDecision denied = QualificationAuthorization.Evaluate(unauthorized); + + foreach (MutationScope scope in Enum.GetValues()) + { + Assert.Equal(WriteRefusal.NotAuthorized, QualificationWriteGuard.EvaluateScope(denied, scope, Target, true)); + } + + Assert.Equal(WriteRefusal.None, + QualificationWriteGuard.EvaluateScope(Allowed, MutationScope.AuthorizedTarget, Target, false)); + Assert.Equal(WriteRefusal.TargetNotAuthorized, + QualificationWriteGuard.EvaluateScope(Allowed, MutationScope.AuthorizedTarget, Target + 1, false)); + Assert.Equal(WriteRefusal.TargetNotAuthorized, + QualificationWriteGuard.EvaluateScope(Allowed, MutationScope.AuthorizedTarget, 0, true)); + Assert.Equal(WriteRefusal.None, + QualificationWriteGuard.EvaluateScope(Allowed, MutationScope.OwnedResource, Target + 1, false)); + Assert.Equal(WriteRefusal.None, + QualificationWriteGuard.EvaluateScope(Allowed, MutationScope.FileAsProcessTarget, 0, true)); + Assert.Equal(WriteRefusal.TargetNotFileAsProcess, + QualificationWriteGuard.EvaluateScope(Allowed, MutationScope.FileAsProcessTarget, Target, false)); + Assert.Equal(WriteRefusal.NotAuthorized, + QualificationWriteGuard.EvaluateScope(Allowed, (MutationScope) 99, Target, false)); + } + + [Fact] + public void AbsentDeclarationRefusesEveryWrite() + { + const int Target = FakeQualificationEnvironment.TargetProcessId; + + Assert.Equal(WriteRefusal.NoDeclaration, QualificationWriteGuard.Evaluate(Allowed, Target, null, ScratchBase, 8)); + Assert.Equal(WriteRefusal.NoDeclaration, + QualificationWriteGuard.Evaluate(Allowed, Target, new TargetDeclaration(Target, []), ScratchBase, 8)); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/README.md b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/README.md new file mode 100644 index 0000000..216ae70 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/README.md @@ -0,0 +1,54 @@ +# CheatEngine.Client.LivePlugin.Qualification.Tests + +## Context + +Tests of the Client qualification harness +([`CheatEngine.Client.LivePlugin.Qualification`](../CheatEngine.Client.LivePlugin.Qualification/README.md)). + +## Why this project exists + +The harness runs only inside Cheat Engine, so it is not exercised by CI in its real setting. Its safety rules must +still be proven on every change: the gate that refuses a mutating function, the guard that confines writes to the +declared scratch region, the fault switch, and the redaction of observations and logs. The Lua-free harness files +(`Harness/`) are compiled into this module; the x64 plugin project itself is never referenced, because an AnyCPU test +module referencing an x64 project is a processor-architecture mismatch. + +## How it helps improve CheatEngine.Client + +- `QualificationAuthorizationTests`: the gate refuses without the exact acknowledgement, with an expired, too long-lived + or malformed manifest, on another host binary or version, for a target that is the host, has exited or has another + image, and authorizes only the declared target (`AuthorizationIsDeniedWithoutTheAcknowledgement`, + `AuthorizationIsDeniedWhenTheManifestExpired`, `AuthorizationIsDeniedWhenTheHostHashDiffers`, + `AuthorizationIsDeniedForAnotherProcessId`, `AuthorizedManifestAllowsOnlyTheDeclaredTarget`). +- `QualificationWriteGuardTests`: a write outside the declared region, for another process or without a declaration is + refused, and no mutation scope relaxes the gate (`WritesOutsideADeclaredRegionAreRefused`, + `WritesAreRefusedWhenTheDeclarationOrTheClientTargetNamesAnotherProcess`, + `MutationScopesNeverRelaxTheGateAndNameTheirOwnTarget`, `AbsentDeclarationRefusesEveryWrite`). +- `QualificationInputsTests`: the runner's `CECLIENT_QUALIFICATION_*` inputs are ignored without authorization, the + Auto Assembler opt-in needs the exact value `1`, and only absolute paths count + (`InputsAreIgnoredWhenTheGateDeniedTheRun`, `OnlyTheExactValueOneComposesTheAutoAssemblerOptIn`). +- `QualificationLifecycleSinkTests`: the Q43 sink appends one line per lifecycle entry and log template, never a + formatted message, and counts a failed write instead of throwing + (`LedgerEntriesAndLogTemplatesAreAppendedOneLineEach`, `AFailedWriteIsCountedNeverThrown`). +- `QualificationFaultInjectionTests`: the runner's fault switch selects exactly one stage and is ignored without + authorization or with another schema (`FaultFileIsIgnoredWhenAuthorizationIsDenied`, + `FaultFileWithAnUnknownSchemaIsIgnoredAndReported`, `FaultFileSelectsExactlyTheRequestedStage`, + `AbsentFaultFileMeansNoFault`). +- `QualificationObservationWriterTests`: observations name their schema and never carry a local path, a failure + message or exception text, and bound address lists (`ObservationJsonHasTheSchemaIdAndNoLocalPath`, + `ObservationNeverCarriesFailureMessagesOrExceptionText`, `AddressListsAreBoundedToTheFirstAndLastEntries`). +- `CapturingLoggerProviderTests`: the Q46 sink keeps templates, never formatted messages, and counts sensitive data + (`CapturedEventsKeepTemplatesButNeverFormattedMessages`, `SensitiveHitsCountAddressesAndDeclaredValues`). +- `QualificationPluginSourceComplianceTests`: a source scan of the plugin's own files proves it never touches the SDK's + native Lua stack or interop directly (ADR-01) and that every Lua function the harness README marks mutating routes + through `QualificationScenarios.RunMutating` (every `QualificationScenarios*.cs` file of the partial class is read) + (`QualificationPluginUsesOnlyTheClientApiForCheatEngineAccess`, `MutatingLuaFunctionsRouteThroughTheAuthorizationGate`). + +## Run + +From the repository root (none of these tests starts Cheat Engine, touches the registry or reads the Cheat Engine +installation): + +```powershell +dotnet test --project .\tests\CheatEngine.Client.LivePlugin.Qualification.Tests\CheatEngine.Client.LivePlugin.Qualification.Tests.csproj +``` diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/packages.lock.json new file mode 100644 index 0000000..49e1912 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification.Tests/packages.lock.json @@ -0,0 +1,227 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.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.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.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/CheatEngine.Client.LivePlugin.Qualification.csproj b/tests/CheatEngine.Client.LivePlugin.Qualification/CheatEngine.Client.LivePlugin.Qualification.csproj new file mode 100644 index 0000000..4b05beb --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/CheatEngine.Client.LivePlugin.Qualification.csproj @@ -0,0 +1,37 @@ + + + + + CheatEngine.Client.LivePlugin.Qualification + LivePlugin.Qualification + x64 + x64 + true + true + true + true + false + true + + true + + + + + + + + + + + + + diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/CapturingLoggerProvider.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/CapturingLoggerProvider.cs new file mode 100644 index 0000000..bf68b76 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/CapturingLoggerProvider.cs @@ -0,0 +1,169 @@ +using System.Text.RegularExpressions; + +using Microsoft.Extensions.Logging; + +namespace LivePlugin.Qualification.Harness; + +/// One captured log event: where it came from and its message template, never the formatted message. +internal readonly record struct CapturedLogEvent(string Category, int EventId, string? EventName, LogLevel Level, + string Template); + +/// +/// The Q46 log sink of the harness. It is registered in the plugin's logging pipeline and records, for every event +/// the Client and the harness log, the category, event id, level and {OriginalFormat} template. The formatted +/// message and exception text are inspected once, to count (a declared scenario value, a +/// target address of six or more hex digits, or the harness script marker), and are never stored: a formatted message +/// may carry the very data the scenario checks, or a user path the runner would have to redact. +/// +internal sealed partial class CapturingLoggerProvider : ILoggerProvider +{ + /// The marker every harness Auto Assembler or Lua script body contains. + internal const string ScriptMarker = "cheatengine_client_qualification_script"; + + /// The most events kept; older events are dropped and counted. + internal const int Capacity = 512; + + private readonly Lock _gate = new(); + private readonly List _events = []; + private readonly HashSet _sensitive = new(StringComparer.Ordinal); + private int _dropped; + private int _sensitiveHits; + + /// Gets the number of events whose formatted message or exception text contained sensitive data. + internal int SensitiveHits + { + get + { + lock (_gate) + { + return _sensitiveHits; + } + } + } + + /// Gets the number of events dropped because the capacity was reached. + internal int Dropped + { + get + { + lock (_gate) + { + return _dropped; + } + } + } + + /// + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(this, categoryName); + } + + /// + public void Dispose() + { + } + + /// Declares a value a scenario writes or searches, so that its appearance in a log counts as a hit. + internal void DeclareSensitive(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + lock (_gate) + { + _sensitive.Add(value); + } + } + + /// Copies the captured events. + internal IReadOnlyList Events() + { + lock (_gate) + { + return [.. _events]; + } + } + + /// + /// Records one event: its template always, a hit when the formatted text carries sensitive data. The template is + /// also appended to the lifecycle receipt sink when the runner configured one (Q43). + /// + internal void Record(CapturedLogEvent captured, string formatted, string? exceptionText) + { + QualificationLifecycleSink.Log(captured); + lock (_gate) + { + if (IsSensitive(formatted) || (exceptionText is not null && IsSensitive(exceptionText))) + { + _sensitiveHits++; + } + + if (_events.Count == Capacity) + { + _events.RemoveAt(0); + _dropped++; + } + + _events.Add(captured); + } + } + + private bool IsSensitive(string text) + { + if (Address().IsMatch(text) || text.Contains(ScriptMarker, StringComparison.Ordinal)) + { + return true; + } + + foreach (string value in _sensitive) + { + if (text.Contains(value, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + [GeneratedRegex("0x[0-9A-Fa-f]{6,}", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Address(); + + private sealed class CapturingLogger(CapturingLoggerProvider provider, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel != LogLevel.None; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + string template = ""; + if (state is IReadOnlyList> values) + { + foreach (KeyValuePair value in values) + { + if (string.Equals(value.Key, "{OriginalFormat}", StringComparison.Ordinal) && + value.Value is string original) + { + template = original; + } + } + } + + provider.Record(new CapturedLogEvent(category, eventId.Id, eventId.Name, logLevel, template), + formatter(state, exception), exception?.ToString()); + } + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationAuthorization.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationAuthorization.cs new file mode 100644 index 0000000..144928c --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationAuthorization.cs @@ -0,0 +1,291 @@ +using System.Globalization; +using System.Text.Json; + +namespace LivePlugin.Qualification.Harness; + +// Re-implemented from CheatEngineNet/CheatEngine.SDK:tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs (the +// ce77-live-probe-v1 contract of the SDK qualification runner), so one runner-written manifest authorizes any harness. +// The evaluation is a pure function of IQualificationEnvironment, so every refusal is proven by tests without a host. + +/// Why the qualification gate refused, or when it allowed the run. +internal enum AuthorizationDenial +{ + /// The gate allowed the run. + None = 0, + + /// The harness does not run in a 64-bit process. + NotX64Process, + + /// The acknowledgement variable is absent or not the exact phrase. + AcknowledgementMissing, + + /// The manifest variable is absent. + ManifestMissing, + + /// The manifest cannot be read or lacks a required field. + ManifestInvalid, + + /// The manifest names another acknowledgement. + ManifestAcknowledgementMismatch, + + /// The manifest does not mark the target disposable. + TargetNotDisposable, + + /// The manifest has expired. + ManifestExpired, + + /// The manifest is valid for longer than the maximum lifetime. + ManifestLifetimeTooLong, + + /// The host image cannot be inspected. + HostUnavailable, + + /// The host is not the pinned Cheat Engine 7.7.0.10621 x64 executable, or the manifest pins another one. + HostMismatch, + + /// The declared target is the Cheat Engine host itself. + TargetIsHost, + + /// The declared target cannot be inspected. + TargetUnavailable, + + /// The declared target's image differs from the manifest hash. + TargetMismatch +} + +/// The PE facts the gate needs about a process image. Paths never leave the harness. +/// SHA-256 of the image file, upper-case hex. +/// The COFF machine, for example Amd64. +/// The file version resource, or . +internal readonly record struct ProcessImage(string Sha256, string Machine, string? FileVersion); + +/// What the gate reads from the process it runs in; the production implementation is . +internal interface IQualificationEnvironment +{ + /// Gets the current UTC time. + public DateTimeOffset UtcNow + { + get; + } + + /// Gets the id of the process the harness runs in (the Cheat Engine host). + public int CurrentProcessId + { + get; + } + + /// Gets a value indicating whether the process is 64-bit. + public bool Is64BitProcess + { + get; + } + + /// Reads an environment variable of the process. + public string? GetVariable(string name); + + /// Reads a UTF-8 text file; when it is absent or unreadable. + public bool TryReadFile(string path, out string text); + + /// Describes the main image of a process; when it cannot be inspected. + public bool TryDescribeProcessImage(int processId, out ProcessImage image); +} + +/// The gate's decision. It carries hashes and ids only, never a path, so it can be reported as is. +internal sealed record AuthorizationDecision( + AuthorizationDenial Denial, + int TargetProcessId, + string TargetSha256, + DateTimeOffset ExpiresUtc) +{ + /// Gets a value indicating whether the run is authorized. + internal bool IsAllowed => Denial == AuthorizationDenial.None; + + /// Gets a denied decision. + internal static AuthorizationDecision Denied(AuthorizationDenial denial) + { + return new AuthorizationDecision(denial, 0, string.Empty, default); + } + + /// Whether the decision authorizes work on the given process: only the declared disposable target. + internal bool Allows(int processId) + { + return IsAllowed && processId > 0 && processId == TargetProcessId; + } +} + +/// +/// The fail-closed authorization of mutating qualification functions. A command-line switch, a launch profile or a +/// target path alone is never authorization: the operator affirms the exact phrase, the SDK runner supplies a +/// short-lived ce77-live-probe-v1 manifest, the host is the pinned Cheat Engine binary and the declared +/// disposable target is alive with the declared image. +/// +internal static class QualificationAuthorization +{ + internal const string Acknowledgement = "I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET"; + internal const string AcknowledgementVariable = "CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT"; + internal const string ManifestVariable = "CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE"; + internal const string ManifestSchema = "ce77-live-probe-v1"; + + /// SHA-256 of cheatengine-x86_64.exe 7.7.0.10621, the qualifiable host profile. + internal const string ExactCheatEngineSha256 = "9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D"; + + internal const string ExactCheatEngineFileVersion = "7.7.0.10621"; + + /// The longest remaining validity a manifest may declare; the SDK runner writes 30 minutes. + internal static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(30); + + /// Evaluates the gate against the given environment. + internal static AuthorizationDecision Evaluate(IQualificationEnvironment environment) + { + ArgumentNullException.ThrowIfNull(environment); + if (!environment.Is64BitProcess) + { + return AuthorizationDecision.Denied(AuthorizationDenial.NotX64Process); + } + + if (!string.Equals(environment.GetVariable(AcknowledgementVariable), Acknowledgement, StringComparison.Ordinal)) + { + return AuthorizationDecision.Denied(AuthorizationDenial.AcknowledgementMissing); + } + + string? manifestPath = environment.GetVariable(ManifestVariable); + if (string.IsNullOrWhiteSpace(manifestPath)) + { + return AuthorizationDecision.Denied(AuthorizationDenial.ManifestMissing); + } + + if (!environment.TryReadFile(manifestPath, out string text) || !TryParseManifest(text, out Manifest manifest)) + { + return AuthorizationDecision.Denied(AuthorizationDenial.ManifestInvalid); + } + + AuthorizationDenial denial = CheckManifest(manifest, environment.UtcNow); + if (denial == AuthorizationDenial.None) + { + denial = CheckHost(manifest, environment); + } + + if (denial == AuthorizationDenial.None) + { + denial = CheckTarget(manifest, environment); + } + + return denial == AuthorizationDenial.None + ? new AuthorizationDecision(AuthorizationDenial.None, manifest.TargetProcessId, manifest.TargetSha256, + manifest.ExpiresUtc) + : AuthorizationDecision.Denied(denial); + } + + private static AuthorizationDenial CheckManifest(Manifest manifest, DateTimeOffset now) + { + if (!string.Equals(manifest.Acknowledgement, Acknowledgement, StringComparison.Ordinal)) + { + return AuthorizationDenial.ManifestAcknowledgementMismatch; + } + + if (!manifest.Disposable) + { + return AuthorizationDenial.TargetNotDisposable; + } + + if (manifest.ExpiresUtc <= now) + { + return AuthorizationDenial.ManifestExpired; + } + + return manifest.ExpiresUtc - now > MaximumLifetime + ? AuthorizationDenial.ManifestLifetimeTooLong + : AuthorizationDenial.None; + } + + private static AuthorizationDenial CheckHost(Manifest manifest, IQualificationEnvironment environment) + { + if (!environment.TryDescribeProcessImage(environment.CurrentProcessId, out ProcessImage host)) + { + return AuthorizationDenial.HostUnavailable; + } + + bool exactHost = string.Equals(host.Sha256, ExactCheatEngineSha256, StringComparison.Ordinal) && + string.Equals(host.Machine, "Amd64", StringComparison.Ordinal) && + string.Equals(host.FileVersion, ExactCheatEngineFileVersion, StringComparison.Ordinal); + return exactHost && string.Equals(manifest.HostSha256, ExactCheatEngineSha256, StringComparison.Ordinal) + ? AuthorizationDenial.None + : AuthorizationDenial.HostMismatch; + } + + private static AuthorizationDenial CheckTarget(Manifest manifest, IQualificationEnvironment environment) + { + if (manifest.TargetProcessId == environment.CurrentProcessId) + { + return AuthorizationDenial.TargetIsHost; + } + + if (!environment.TryDescribeProcessImage(manifest.TargetProcessId, out ProcessImage target)) + { + return AuthorizationDenial.TargetUnavailable; + } + + return string.Equals(target.Sha256, manifest.TargetSha256, StringComparison.Ordinal) + ? AuthorizationDenial.None + : AuthorizationDenial.TargetMismatch; + } + + private static bool TryParseManifest(string text, out Manifest manifest) + { + manifest = default; + try + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !TryString(root, "schema", out string schema) || + !string.Equals(schema, ManifestSchema, 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 expires) || + !root.TryGetProperty("targetProcessId", out JsonElement processId) || + !processId.TryGetInt32(out int targetProcessId) || targetProcessId <= 0 || + !root.TryGetProperty("disposable", out JsonElement disposable) || + disposable.ValueKind is not (JsonValueKind.True or JsonValueKind.False) || + !DateTimeOffset.TryParse(expires, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, + out DateTimeOffset expiresUtc)) + { + return false; + } + + manifest = new Manifest(acknowledgement, NormalizeHash(hostSha256), targetProcessId, + NormalizeHash(targetSha256), disposable.GetBoolean(), expiresUtc); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool TryString(JsonElement parent, string name, out string value) + { + value = string.Empty; + if (!parent.TryGetProperty(name, out JsonElement property) || property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString() ?? string.Empty; + return value.Trim().Length > 0; + } + + private static string NormalizeHash(string hash) + { + return hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); + } + + private readonly record struct Manifest( + string Acknowledgement, + string HostSha256, + int TargetProcessId, + string TargetSha256, + bool Disposable, + DateTimeOffset ExpiresUtc); +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationEnvironment.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationEnvironment.cs new file mode 100644 index 0000000..630c05e --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationEnvironment.cs @@ -0,0 +1,118 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; + +namespace LivePlugin.Qualification.Harness; + +/// +/// The production : process variables, files, the clock, and read-only facts +/// about process images (SHA-256, COFF machine, file version). It opens no handle other than a read of the image files +/// and never writes anything. +/// +internal sealed class QualificationEnvironment : IQualificationEnvironment +{ + /// The shared instance. + internal static readonly QualificationEnvironment Instance = new(); + + private readonly Dictionary _imagesByPath = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _gate = new(); + + private QualificationEnvironment() + { + } + + /// + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + + /// + public int CurrentProcessId => Environment.ProcessId; + + /// + public bool Is64BitProcess => Environment.Is64BitProcess; + + /// + public string? GetVariable(string name) + { + return Environment.GetEnvironmentVariable(name); + } + + /// + public bool TryReadFile(string path, out string text) + { + text = string.Empty; + try + { + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + { + return false; + } + + text = File.ReadAllText(fullPath); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException) + { + return false; + } + } + + /// + public bool TryDescribeProcessImage(int processId, out ProcessImage image) + { + image = default; + string? path; + try + { + using Process process = Process.GetProcessById(processId); + path = process.MainModule?.FileName; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException + or NotSupportedException or Win32Exception) + { + return false; + } + + if (string.IsNullOrEmpty(path)) + { + return false; + } + + lock (_gate) + { + if (_imagesByPath.TryGetValue(path, out image)) + { + return true; + } + } + + try + { + string sha256; + string machine; + using (FileStream stream = File.OpenRead(path)) + { + sha256 = Convert.ToHexString(SHA256.HashData(stream)); + stream.Position = 0; + using PEReader reader = new(stream, PEStreamOptions.LeaveOpen); + machine = reader.PEHeaders.CoffHeader.Machine.ToString(); + } + + image = new ProcessImage(sha256, machine, FileVersionInfo.GetVersionInfo(path).FileVersion); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or BadImageFormatException) + { + return false; + } + + lock (_gate) + { + _imagesByPath[path] = image; + } + + return true; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationFaultSwitch.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationFaultSwitch.cs new file mode 100644 index 0000000..58b5157 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationFaultSwitch.cs @@ -0,0 +1,117 @@ +using System.Text.Json; + +namespace LivePlugin.Qualification.Harness; + +/// Where the harness throws on purpose for the rollback (Q06) and cleanup (Q43) scenarios. +internal enum FaultStage +{ + /// No fault. + None = 0, + + /// Configure throws before any activation exists. + Configure, + + /// The fault module throws in OnEnabled, after the first recording module entered. + ModuleOnEnabled, + + /// The fault module throws in OnDisabling. + ModuleOnDisabling, + + /// The activation-scoped resource throws in Dispose. + ResourceCleanup, + + /// Both and . + ModuleOnDisablingAndResourceCleanup +} + +/// Why the fault switch selected what it selected. +internal enum FaultSwitchReason +{ + /// No switch file exists next to the plugin. + Absent = 0, + + /// The switch was read and selects a stage (possibly ). + Selected, + + /// A switch exists but the gate did not authorize the run, so it is ignored. + IgnoredUnauthorized, + + /// The switch has another schema, is malformed or names an unknown stage, so it is ignored. + IgnoredInvalid +} + +/// The decision of one enable: the stage to fault and why. A reference type, so it can be a DI singleton. +internal sealed record FaultDecision(FaultStage Stage, FaultSwitchReason Reason) +{ + /// Gets the decision when no switch exists. + internal static FaultDecision NoFault + { + get; + } = new(FaultStage.None, FaultSwitchReason.Absent); + + /// Whether a module OnEnabled fault is selected. + internal bool FaultsEnable => Stage == FaultStage.ModuleOnEnabled; + + /// Whether a module OnDisabling fault is selected. + internal bool FaultsDisabling => Stage is FaultStage.ModuleOnDisabling or FaultStage.ModuleOnDisablingAndResourceCleanup; + + /// Whether a resource cleanup fault is selected. + internal bool FaultsResourceCleanup => Stage is FaultStage.ResourceCleanup or FaultStage.ModuleOnDisablingAndResourceCleanup; +} + +/// +/// Reads the fault switch the CheatEngine.SDK qualification runner writes next to the first plugin of a scenario that +/// declares a faultStage (liveprobe.fault.json, schema ce77-live-probe-fault-v1, removed by the +/// runner when an operator step says removeFaultFile). The switch is read once per enable and honored only +/// when the qualification gate authorized the run, so a stray file on a user's machine never breaks a plugin. +/// +internal static class QualificationFaultSwitch +{ + internal const string FileName = "liveprobe.fault.json"; + internal const string Schema = "ce77-live-probe-fault-v1"; + + /// Reads the switch of under the given gate decision. + internal static FaultDecision Read(string? pluginDirectory, AuthorizationDecision authorization, + IQualificationEnvironment environment) + { + ArgumentNullException.ThrowIfNull(authorization); + ArgumentNullException.ThrowIfNull(environment); + if (string.IsNullOrEmpty(pluginDirectory) || + !environment.TryReadFile(Path.Combine(pluginDirectory, FileName), out string text)) + { + return FaultDecision.NoFault; + } + + if (!authorization.IsAllowed) + { + return new FaultDecision(FaultStage.None, FaultSwitchReason.IgnoredUnauthorized); + } + + return TryParse(text, out FaultStage stage) + ? new FaultDecision(stage, FaultSwitchReason.Selected) + : new FaultDecision(FaultStage.None, FaultSwitchReason.IgnoredInvalid); + } + + private static bool TryParse(string text, out FaultStage stage) + { + stage = FaultStage.None; + try + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + return root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("schema", out JsonElement schema) && + schema.ValueKind == JsonValueKind.String && + string.Equals(schema.GetString(), Schema, StringComparison.Ordinal) && + root.TryGetProperty("throwIn", out JsonElement throwIn) && + throwIn.ValueKind == JsonValueKind.String && + Enum.TryParse(throwIn.GetString(), ignoreCase: false, out stage) && + Enum.IsDefined(stage); + } + catch (JsonException) + { + stage = FaultStage.None; + return false; + } + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationInputs.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationInputs.cs new file mode 100644 index 0000000..608c8ce --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationInputs.cs @@ -0,0 +1,48 @@ +namespace LivePlugin.Qualification.Harness; + +/// +/// The session inputs the live qualification runner hands the harness through CECLIENT_QUALIFICATION_* +/// process variables. They are read once per enable and honored only when the qualification gate authorized the run, +/// so a stray variable on a user's machine never composes an opt-in or writes a file. +/// +/// +/// Whether the activation composes EnableAutoAssemblerPatches() (Q35): only for the exact value 1. +/// +/// The one allowed table root of the activation (Q34), an absolute path, or . +/// +/// The lifecycle receipt sink (Q43): an absolute file the harness appends its lifecycle record and captured log +/// templates to, so the runner reads what happened after the last Lua call (the disable at closeCE). +/// +internal sealed record QualificationInputs(bool EnableAutoAssembler, string? TableRoot, string? LifecycleFile) +{ + internal const string EnableAutoAssemblerVariable = "CECLIENT_QUALIFICATION_ENABLE_AA"; + internal const string TableRootVariable = "CECLIENT_QUALIFICATION_TABLE_ROOT"; + internal const string LifecycleFileVariable = "CECLIENT_QUALIFICATION_LIFECYCLE_FILE"; + + /// Gets the inputs of an unauthorized run: no opt-in, no table root, no sink. + internal static QualificationInputs None + { + get; + } = new(false, null, null); + + /// Reads the inputs under the gate decision of this enable. + internal static QualificationInputs Read(AuthorizationDecision authorization, IQualificationEnvironment environment) + { + ArgumentNullException.ThrowIfNull(authorization); + ArgumentNullException.ThrowIfNull(environment); + if (!authorization.IsAllowed) + { + return None; + } + + return new QualificationInputs( + string.Equals(environment.GetVariable(EnableAutoAssemblerVariable), "1", StringComparison.Ordinal), + AbsolutePath(environment.GetVariable(TableRootVariable)), + AbsolutePath(environment.GetVariable(LifecycleFileVariable))); + } + + private static string? AbsolutePath(string? value) + { + return !string.IsNullOrWhiteSpace(value) && Path.IsPathFullyQualified(value) ? Path.GetFullPath(value) : null; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationLedger.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationLedger.cs new file mode 100644 index 0000000..9ee3a73 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationLedger.cs @@ -0,0 +1,149 @@ +namespace LivePlugin.Qualification.Harness; + +/// +/// The harness's record of its own lifecycle, kept in static state because Cheat Engine never unloads a managed +/// plugin: after a failed enable (Q06) or a faulty disable (Q43), the next enable reports what the previous one did. +/// Every entry is #<enable number> <stage>[ <exception type>]: stage names and exception +/// type names only, never a message. The record is bounded; the oldest entries are dropped and counted. Every entry is +/// also appended to the lifecycle receipt sink when the runner configured one (). +/// +internal static class QualificationLedger +{ + internal const int Capacity = 128; + + private static readonly Lock Gate = new(); + private static readonly List RecordedEntries = []; + private static int _enableAttempts; + private static int _activations; + private static long _lastEpoch; + private static long _previousEpoch; + private static int _dropped; + private static FaultDecision _lastFault = FaultDecision.NoFault; + + /// Gets the number of enables that reached Configure. + internal static int EnableAttempts + { + get + { + lock (Gate) + { + return _enableAttempts; + } + } + } + + /// Gets the number of enables whose modules all entered. + internal static int Activations + { + get + { + lock (Gate) + { + return _activations; + } + } + } + + /// Gets the Client epoch of the last successful enable. + internal static long LastEpoch + { + get + { + lock (Gate) + { + return _lastEpoch; + } + } + } + + /// Gets the Client epoch of the successful enable before the last one, or 0. + internal static long PreviousEpoch + { + get + { + lock (Gate) + { + return _previousEpoch; + } + } + } + + /// Gets the number of entries dropped because the capacity was reached. + internal static int Dropped + { + get + { + lock (Gate) + { + return _dropped; + } + } + } + + /// Gets the fault decision of the last enable. + internal static FaultDecision LastFault + { + get + { + lock (Gate) + { + return _lastFault; + } + } + } + + /// Starts the record of a new enable and remembers its fault decision. + internal static void BeginEnable(FaultDecision fault) + { + ArgumentNullException.ThrowIfNull(fault); + lock (Gate) + { + _enableAttempts++; + _lastFault = fault; + AddLocked("configure fault=" + fault.Stage + " reason=" + fault.Reason); + } + } + + /// Records a completed enable with its Client epoch. + internal static void RecordActivated(long epoch) + { + lock (Gate) + { + _activations++; + _previousEpoch = _lastEpoch; + _lastEpoch = epoch; + AddLocked("activated"); + } + } + + /// Records one stage, with the type name of the exception it threw when it failed. + internal static void Record(string stage, Exception? failure = null) + { + lock (Gate) + { + AddLocked(failure is null ? stage : stage + " " + failure.GetType().Name); + } + } + + /// Copies the entries, oldest first. + internal static IReadOnlyList Entries() + { + lock (Gate) + { + return [.. RecordedEntries]; + } + } + + private static void AddLocked(string entry) + { + if (RecordedEntries.Count == Capacity) + { + RecordedEntries.RemoveAt(0); + _dropped++; + } + + string recorded = "#" + _enableAttempts.ToString(System.Globalization.CultureInfo.InvariantCulture) + " " + entry; + RecordedEntries.Add(recorded); + QualificationLifecycleSink.Ledger(recorded); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationLifecycleSink.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationLifecycleSink.cs new file mode 100644 index 0000000..9ba96f0 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationLifecycleSink.cs @@ -0,0 +1,99 @@ +using System.Text; + +namespace LivePlugin.Qualification.Harness; + +/// +/// The lifecycle receipt sink of Q43. The harness cannot answer a Lua call once Cheat Engine disables it (at +/// closeCE or through Settings > Plugins), so it appends its lifecycle record and the templates of the log +/// events it captures to the file the runner named (), one line per +/// entry, flushed at once: ledger<TAB>#<enable> <stage>[ <exception type>] or +/// log<TAB><level><TAB><category><TAB><event id><TAB><template>. +/// Like the ledger it never writes a formatted message, an exception message or a path, and a write that fails is +/// counted, never thrown: the sink must not change the lifecycle it records. +/// +internal static class QualificationLifecycleSink +{ + private static readonly Lock Gate = new(); + private static string? _path; + private static int _failedWrites; + + /// Gets the number of lines that could not be written. + internal static int FailedWrites + { + get + { + lock (Gate) + { + return _failedWrites; + } + } + } + + /// Gets whether a sink file is configured for the current enable. + internal static bool IsConfigured + { + get + { + lock (Gate) + { + return _path is not null; + } + } + } + + /// Sets the sink file of this enable, or none; an unauthorized run never has one. + internal static void Configure(string? path) + { + lock (Gate) + { + _path = path; + } + } + + /// Appends one lifecycle record entry. + internal static void Ledger(string entry) + { + Append("ledger\t" + Clean(entry)); + } + + /// Appends the template of one captured log event. + internal static void Log(CapturedLogEvent captured) + { + Append(string.Join('\t', "log", captured.Level.ToString(), Clean(captured.Category), + captured.EventId.ToString(System.Globalization.CultureInfo.InvariantCulture), Clean(captured.Template))); + } + + /// Replaces the control characters of a field, so every entry stays on one line with its fields apart. + internal static string Clean(string value) + { + ArgumentNullException.ThrowIfNull(value); + StringBuilder cleaned = new(value.Length); + foreach (char character in value) + { + cleaned.Append(char.IsControl(character) ? ' ' : character); + } + + return cleaned.ToString(); + } + + private static void Append(string line) + { + lock (Gate) + { + if (_path is null) + { + return; + } + + try + { + File.AppendAllText(_path, line + "\n", new UTF8Encoding(false)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or NotSupportedException or ArgumentException) + { + _failedWrites++; + } + } + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationObservation.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationObservation.cs new file mode 100644 index 0000000..db358c2 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/Harness/QualificationObservation.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Results; + +namespace LivePlugin.Qualification.Harness; + +/// +/// Builds the JSON observation a harness Lua function returns (schema +/// cheatengine-client-qualification-observation/v0). Written with only, so the +/// trimming and AOT analysis of the plugin stay clean. It is bounded and redacted by construction: a string that looks +/// like a local path is replaced, a failure is written as its kind, operation and host effect only (never its message +/// or exception text, which may carry user data: Q46), and an address list is written as its count and first and last +/// eight entries. +/// +internal sealed partial class QualificationObservation : IDisposable +{ + internal const string Schema = "cheatengine-client-qualification-observation/v0"; + internal const string RedactedPath = ""; + internal const int AddressListEdge = 8; + + private static readonly JsonWriterOptions Options = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private readonly MemoryStream _stream = new(); + private readonly Utf8JsonWriter _writer; + private bool _completed; + + /// Starts the observation of one harness function. + internal QualificationObservation(string function) + { + _writer = new Utf8JsonWriter(_stream, Options); + _writer.WriteStartObject(); + _writer.WriteString("schema", Schema); + _writer.WriteString("function", function); + } + + /// Writes a string value, redacting anything that looks like a local path. + internal QualificationObservation String(string name, string? value) + { + if (value is null) + { + _writer.WriteNull(name); + } + else + { + _writer.WriteString(name, LooksLikeLocalPath(value) ? RedactedPath : value); + } + + return this; + } + + /// Writes an integer value. + internal QualificationObservation Number(string name, long value) + { + _writer.WriteNumber(name, value); + return this; + } + + /// Writes an integer value, or null when the Client reports the fact as unknown. + internal QualificationObservation OptionalNumber(string name, long? value) + { + if (value is { } known) + { + _writer.WriteNumber(name, known); + } + else + { + _writer.WriteNull(name); + } + + return this; + } + + /// Writes a boolean value. + internal QualificationObservation Boolean(string name, bool value) + { + _writer.WriteBoolean(name, value); + return this; + } + + /// Writes a boolean value, or null when the Client reports the fact as unknown. + internal QualificationObservation OptionalBoolean(string name, bool? value) + { + if (value is { } known) + { + _writer.WriteBoolean(name, known); + } + else + { + _writer.WriteNull(name); + } + + return this; + } + + /// Writes a target address as 0x upper-case hex. + internal QualificationObservation Address(string name, ulong value) + { + _writer.WriteString(name, Hex(value)); + return this; + } + + /// Writes a failure as its kind, operation and host effect; its message and exception are never written. + internal QualificationObservation Failure(string name, CheatEngineFailure failure) + { + _writer.WriteStartObject(name); + _writer.WriteString("kind", failure.Kind.ToString()); + _writer.WriteString("operation", failure.Operation); + _writer.WriteString("hostEffect", failure.HostEffect.ToString()); + _writer.WriteEndObject(); + return this; + } + + /// Writes an address list as its count and its first and last entries. + internal QualificationObservation Addresses(string name, IReadOnlyList addresses) + { + ArgumentNullException.ThrowIfNull(addresses); + _writer.WriteStartObject(name); + _writer.WriteNumber("count", addresses.Count); + _writer.WriteStartArray("first"); + for (int index = 0; index < Math.Min(AddressListEdge, addresses.Count); index++) + { + _writer.WriteStringValue(Hex(addresses[index])); + } + + _writer.WriteEndArray(); + _writer.WriteStartArray("last"); + for (int index = Math.Max(AddressListEdge, addresses.Count - AddressListEdge); index < addresses.Count; index++) + { + _writer.WriteStringValue(Hex(addresses[index])); + } + + _writer.WriteEndArray(); + _writer.WriteEndObject(); + return this; + } + + /// Writes a list of short strings (names, stage records), each redacted like . + internal QualificationObservation Strings(string name, IEnumerable values) + { + ArgumentNullException.ThrowIfNull(values); + _writer.WriteStartArray(name); + foreach (string value in values) + { + _writer.WriteStringValue(LooksLikeLocalPath(value) ? RedactedPath : value); + } + + _writer.WriteEndArray(); + return this; + } + + /// Opens a nested object. + internal QualificationObservation BeginObject(string name) + { + _writer.WriteStartObject(name); + return this; + } + + /// Closes the nested object. + internal QualificationObservation EndObject() + { + _writer.WriteEndObject(); + return this; + } + + /// Opens a nested array of objects. + internal QualificationObservation BeginArray(string name) + { + _writer.WriteStartArray(name); + return this; + } + + /// Opens an object inside the current array. + internal QualificationObservation BeginItem() + { + _writer.WriteStartObject(); + return this; + } + + /// Closes the current array. + internal QualificationObservation EndArray() + { + _writer.WriteEndArray(); + return this; + } + + /// Closes the observation and returns its JSON text. + internal string Complete() + { + if (!_completed) + { + _writer.WriteEndObject(); + _writer.Flush(); + _completed = true; + } + + return Encoding.UTF8.GetString(_stream.ToArray()); + } + + /// + public void Dispose() + { + _writer.Dispose(); + _stream.Dispose(); + } + + /// Whether a text holds a drive-rooted path, a user profile segment, a home directory or a file URI. + internal static bool LooksLikeLocalPath(string value) + { + return LocalPath().IsMatch(value); + } + + /// Formats a target address. + internal static string Hex(ulong value) + { + return "0x" + value.ToString("X", CultureInfo.InvariantCulture); + } + + [GeneratedRegex(@"(?A target range a mutating qualification function may write: declared, bounded, never inferred. +/// The region's name in observations, for example scratch. +/// The first byte of the region in the target. +/// The number of writable bytes. +internal readonly record struct WritableRegion(string Id, ulong Base, int Length) +{ + /// Whether bytes from lie inside the region. + internal bool Contains(ulong address, int length) + { + if (length <= 0 || Length <= 0 || address < Base) + { + return false; + } + + ulong offset = address - Base; + return offset <= (ulong) Length && (ulong) length <= (ulong) Length - offset; + } +} + +/// +/// The writable regions declared for one target process by cheatengine_client_qualification_target_declare +/// after it verified them through the Client inspection API. +/// +/// The process the regions belong to. +/// The declared regions. +internal sealed record TargetDeclaration(int ProcessId, IReadOnlyList Regions); + +/// Why a write was refused, or when it is allowed. +internal enum WriteRefusal +{ + /// The write is allowed. + None = 0, + + /// The qualification gate did not authorize the run. + NotAuthorized, + + /// The Client observes no target, or another process than the authorized one. + TargetNotAuthorized, + + /// No writable region was declared. + NoDeclaration, + + /// The declaration names another process than the authorized, Client-visible one. + DeclarationForAnotherProcess, + + /// The range is empty or outside every declared region. + OutsideDeclaredRegion, + + /// The step is allowed only while Cheat Engine targets a file opened as a process, and it does not. + TargetNotFileAsProcess +} + +/// Which target a mutating harness step may run against. +internal enum MutationScope +{ + /// The step runs only while the Client observes exactly the authorized target (the default). + AuthorizedTarget = 0, + + /// + /// The step only releases or inspects a resource the harness created on the authorized target, so it runs after a + /// target change too: the Client must refuse to free anything in another process, and that refusal is what S3 + /// observes. + /// + OwnedResource, + + /// + /// The step runs only while Cheat Engine targets a file opened as a process (S3), where no process exists that a + /// write could reach; it checks that the Client refuses target-bound resources there. + /// + FileAsProcessTarget +} + +/// +/// The fail-closed rule of every mutating harness function: a write reaches the Client memory API only when the gate +/// authorized the run, the Client observes exactly the authorized target, a declaration exists for that process, and +/// the whole written range lies inside one declared region. Pure, so every refusal is proven without a host. +/// +internal static class QualificationWriteGuard +{ + /// + /// The first 64 KiB of a Windows user address space are never mapped. The partial-batch scenario (Q33) writes one + /// element there on purpose, so that Cheat Engine refuses it with no effect anywhere. + /// + internal const ulong NeverMappedLimit = 0x10000; + + /// Whether an address lies in the never-mapped null region. + internal static bool IsNeverMapped(ulong address) + { + return address < NeverMappedLimit; + } + + /// + /// Evaluates which target a mutating step may run against: an authorized run always, and then the scope's own + /// target rule. + /// + /// The gate decision of the enable. + /// The step's scope. + /// The process the Client observes, or 0. + /// Whether the Client observes a file opened as a process. + internal static WriteRefusal EvaluateScope(AuthorizationDecision authorization, MutationScope scope, + int clientProcessId, bool fileAsProcess) + { + ArgumentNullException.ThrowIfNull(authorization); + if (!authorization.IsAllowed) + { + return WriteRefusal.NotAuthorized; + } + + return scope switch + { + MutationScope.AuthorizedTarget when !authorization.Allows(clientProcessId) => WriteRefusal.TargetNotAuthorized, + MutationScope.FileAsProcessTarget when !fileAsProcess => WriteRefusal.TargetNotFileAsProcess, + MutationScope.AuthorizedTarget or MutationScope.OwnedResource or MutationScope.FileAsProcessTarget => + WriteRefusal.None, + _ => WriteRefusal.NotAuthorized + }; + } + + /// Evaluates one intended write of bytes at . + internal static WriteRefusal Evaluate(AuthorizationDecision authorization, int clientProcessId, + TargetDeclaration? declaration, ulong address, int length) + { + ArgumentNullException.ThrowIfNull(authorization); + if (!authorization.IsAllowed) + { + return WriteRefusal.NotAuthorized; + } + + if (!authorization.Allows(clientProcessId)) + { + return WriteRefusal.TargetNotAuthorized; + } + + if (declaration is null || declaration.Regions.Count == 0) + { + return WriteRefusal.NoDeclaration; + } + + if (declaration.ProcessId != clientProcessId) + { + return WriteRefusal.DeclarationForAnotherProcess; + } + + foreach (WritableRegion region in declaration.Regions) + { + if (region.Contains(address, length)) + { + return WriteRefusal.None; + } + } + + return WriteRefusal.OutsideDeclaredRegion; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationLuaFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationLuaFunctions.cs new file mode 100644 index 0000000..7d3d8b8 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationLuaFunctions.cs @@ -0,0 +1,166 @@ +using CheatEngine.SDK.Annotations.Lua; + +namespace LivePlugin.Qualification; + +/// +/// The Lua functions of the qualification harness, one per Client C3/C4 scenario step (see the project README). +/// Every function returns one JSON observation. The functions that change the target or Cheat Engine state (the +/// README marks them) run through , which refuses them unless the +/// qualification gate authorized the run and, by default, the Client observes exactly the authorized target. +/// +internal static partial class QualificationLuaFunctions +{ + /// Plugin identity, gate and fault decisions, activation history and bridge hash. + [LuaFunction("cheatengine_client_qualification_status")] + public static string Status() + { + return QualificationScenarios.Status(); + } + + /// The Client process and runtime snapshots, the process id first. + [LuaFunction("cheatengine_client_qualification_runtime")] + public static string Runtime() + { + return QualificationScenarios.Runtime(); + } + + /// Capability availability; with = 0, one harmless call per unavailable family. + [LuaFunction("cheatengine_client_qualification_capabilities")] + public static string Capabilities(long probeOnly) + { + return QualificationScenarios.Capabilities(probeOnly); + } + + /// Declares the driver-allocated scratch region after verifying it through the Client. + [LuaFunction("cheatengine_client_qualification_target_declare")] + public static string TargetDeclare(string symbolName) + { + return QualificationScenarios.DeclareTarget(symbolName); + } + + /// One Client AOB scan with its detailed outcome. + [LuaFunction("cheatengine_client_qualification_aob")] + public static string Aob(string pattern, string moduleName, long maxResults, long cancelAfterMs) + { + return QualificationScenarios.Aob(pattern, moduleName, maxResults, cancelAfterMs); + } + + /// Writes, reads back and restores one boundary value in the declared scratch region. + [LuaFunction("cheatengine_client_qualification_memory_roundtrip")] + public static string MemoryRoundTrip(string kind, string symbolName) + { + return QualificationScenarios.MemoryRoundTrip(kind, symbolName); + } + + /// A four-element batch whose third address is never mapped. + [LuaFunction("cheatengine_client_qualification_memory_batch_partial")] + public static string MemoryBatchPartial(string symbolName, long invalidAddress) + { + return QualificationScenarios.MemoryBatchPartial(symbolName, invalidAddress); + } + + /// Creates one memory record on the scratch symbol and remembers its id. + [LuaFunction("cheatengine_client_qualification_table_create")] + public static string TableCreate(string symbolName) + { + return QualificationScenarios.TableCreate(symbolName); + } + + /// Reads the remembered record id again after the driver destroyed the record or reloaded the table. + [LuaFunction("cheatengine_client_qualification_table_probe")] + public static string TableProbe() + { + return QualificationScenarios.TableProbe(); + } + + /// Registers a harness symbol on the scratch address through a Client symbol lease. + [LuaFunction("cheatengine_client_qualification_symbol_register")] + public static string SymbolRegister(string name, string symbolName) + { + return QualificationScenarios.SymbolRegister(name, symbolName); + } + + /// Disposes the harness's symbol lease. + [LuaFunction("cheatengine_client_qualification_symbol_release")] + public static string SymbolRelease(string name) + { + return QualificationScenarios.SymbolRelease(name); + } + + /// The harness's symbol lease and the current resolution of the name. + [LuaFunction("cheatengine_client_qualification_symbol_state")] + public static string SymbolState(string name) + { + return QualificationScenarios.SymbolState(name); + } + + /// The captured log templates and the sensitive-data hit count. + [LuaFunction("cheatengine_client_qualification_logs")] + public static string Logs() + { + return QualificationScenarios.Logs(); + } + + /// One value-scan step on the scratch region (Q25, Q26). + [LuaFunction("cheatengine_client_qualification_value_scan")] + public static string ValueScan(string action, string symbolName, long value) + { + return QualificationScenarios.ValueScan(action, symbolName, value); + } + + /// One allocation step under a harness-prefixed name (Q30.a, Q30.b). + [LuaFunction("cheatengine_client_qualification_allocation")] + public static string Allocation(string action, string name, long size) + { + return QualificationScenarios.Allocation(action, name, size); + } + + /// The instruction profile of the selected target, with a round trip in the scratch region (Q32). + [LuaFunction("cheatengine_client_qualification_instructions")] + public static string Instructions(string symbolName) + { + return QualificationScenarios.Instructions(symbolName); + } + + /// One Auto Assembler patch step, only when the run composed the opt-in (Q35, Q44). + [LuaFunction("cheatengine_client_qualification_aa_patch")] + public static string AaPatch(string action, string variant) + { + return QualificationScenarios.AutoAssemblerPatch(action, variant); + } + + /// Starts the worker admission probe, or reads its result (Q19). + [LuaFunction("cheatengine_client_qualification_worker_admission")] + public static string WorkerAdmission(string action) + { + return QualificationScenarios.WorkerAdmission(action); + } + + /// Saves the current table below the runner's table root, or outside it to observe the refusal (Q34). + [LuaFunction("cheatengine_client_qualification_table_save")] + public static string TableSave(string fileName, long outsideRoot) + { + return QualificationScenarios.TableSave(fileName, outsideRoot); + } + + /// Loads a table from the runner's table root (Q34). + [LuaFunction("cheatengine_client_qualification_table_load")] + public static string TableLoad(string fileName) + { + return QualificationScenarios.TableLoad(fileName); + } + + /// Returns the integer CheatEngine.SDK marshalled (CRIT-07: floats from 2^53 on are refused). + [LuaFunction("cheatengine_client_qualification_integer_echo")] + public static string IntegerEcho(long value) + { + return QualificationScenarios.IntegerEcho(value); + } + + /// Returns the target address () CheatEngine.SDK marshalled (CRIT-07). + [LuaFunction("cheatengine_client_qualification_address_echo")] + public static string AddressEcho(nuint address) + { + return QualificationScenarios.AddressEcho(address); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs new file mode 100644 index 0000000..80a916a --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs @@ -0,0 +1,241 @@ +using CheatEngine.Client; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Modules; +using CheatEngine.SDK.Annotations.Plugin; + +using LivePlugin.Qualification.Harness; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LivePlugin.Qualification; + +/// +/// The Client qualification harness: a real composed like an application, whose +/// Lua functions run one Client C3/C4 scenario each through the public Client API only. It evaluates the +/// qualification gate, the fault switch and the session inputs once per enable, registers the Q46 log sink and the +/// Hosting host log provider, composes the Auto Assembler opt-in and the table root only when the runner asked for +/// them in an authorized run, and records its own lifecycle (also into the Q43 lifecycle sink) so that a re-enable, +/// or the runner after closeCE, can report what a failed enable or a faulty disable did. +/// +[CheatEnginePlugin(DisplayName)] +public sealed class QualificationPlugin : CheatEngineClientPlugin +{ + /// The name Cheat Engine shows in Edit > Settings > Plugins. + internal const string DisplayName = "CheatEngine.Client Qualification Plugin"; + + /// + protected override void Configure(CheatEnginePluginBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + string? pluginDirectory = ReadPluginDirectory(builder); + AuthorizationDecision authorization = QualificationAuthorization.Evaluate(QualificationEnvironment.Instance); + FaultDecision fault = QualificationFaultSwitch.Read(pluginDirectory, authorization, + QualificationEnvironment.Instance); + QualificationInputs inputs = QualificationInputs.Read(authorization, QualificationEnvironment.Instance); + QualificationLifecycleSink.Configure(inputs.LifecycleFile); + QualificationSession.BeginEnable(Context.PluginId, authorization, inputs, pluginDirectory); + QualificationLedger.BeginEnable(fault); + if (fault.Stage == FaultStage.Configure) + { + InvalidOperationException failure = new("The qualification fault switch selected Configure."); + QualificationLedger.Record("configure.threw", failure); + throw failure; + } + + // Debug events included, so the Q46 sink sees every template the Client and Hosting write. The Hosting host log + // provider writes those templates to the Cheat Engine debug output, which Q46 also reads. + builder.Logging.SetMinimumLevel(LogLevel.Debug).AddProvider(QualificationSession.Logs).AddCheatEngineHostLog(); + builder.Services.AddSingleton(fault); + builder.Services.AddScoped(); + builder.Client + .AddLuaModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule(); + if (inputs.TableRoot is { } tableRoot) + { + builder.Client.Configure(options => + { + options.AllowedTableRoots.Clear(); + options.AllowedTableRoots.Add(tableRoot); + }); + } + + if (inputs.EnableAutoAssembler) + { +#pragma warning disable CECLIENT5004 // Q35 composes the experimental Auto Assembler opt-in only for an authorized run that asked for it. + builder.Client.EnableAutoAssemblerPatches(); +#pragma warning restore CECLIENT5004 + QualificationLedger.Record("configure.auto-assembler-opt-in"); + } + } + + /// + protected override void OnClientEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationLedger.RecordActivated(client.Epoch); + } + + /// + protected override void OnClientDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationLedger.Record("plugin.disabling"); + } + + /// + /// Reads the folder the plugin was loaded from, where the runner writes the fault switch and the bundle + /// keeps the native bridge, through Hosting's . + /// + /// The folder, or when the plugin assembly has no file location. + private static string? ReadPluginDirectory(CheatEnginePluginBuilder builder) + { + try + { + return builder.PluginDirectory; + } + catch (InvalidOperationException) + { + // An assembly without a file location has no folder: it reads as no fault switch and no bridge. + return null; + } + } +} + +/// Declares the generated, activation-scoped Lua module of the harness functions. +[CheatEngineLuaModule(typeof(QualificationLuaFunctions), "client_qualification")] +internal sealed partial class QualificationLuaModule : ILuaModule; + +/// The first module: entered first, disabled last, so it proves that cleanup continues past a faulty module. +internal sealed class QualificationFirstModule : ICheatEngineClientModule +{ + /// + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationLedger.Record("first.enabled"); + } + + /// + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationLedger.Record("first.disabling"); + } +} + +/// +/// The module that throws where the fault switch says (Q06, Q43). It holds the activation-scoped resource, so the +/// resource is created with the activation and disposed with its scope. +/// +internal sealed class QualificationFaultModule(FaultDecision fault, QualificationScopedResource resource) + : ICheatEngineClientModule +{ + /// + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + resource.Acquire(); + if (fault.FaultsEnable) + { + InvalidOperationException failure = new("The qualification fault switch selected OnEnabled."); + QualificationLedger.Record("fault.enabling.threw", failure); + throw failure; + } + + QualificationLedger.Record("fault.enabled"); + } + + /// + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + if (fault.FaultsDisabling) + { + InvalidOperationException failure = new("The qualification fault switch selected OnDisabling."); + QualificationLedger.Record("fault.disabling.threw", failure); + throw failure; + } + + QualificationLedger.Record("fault.disabling"); + } +} + +/// +/// Publishes the activation's Client, with the activation's service provider, to the Lua functions and withdraws it +/// before the activation ends. +/// +internal sealed class QualificationScenarioModule(IServiceProvider services) : ICheatEngineClientModule +{ + /// + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationSession.Attach(client, services); + QualificationLedger.Record("scenario.enabled"); + } + + /// + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationSession.Detach(); + QualificationLedger.Record("scenario.disabling"); + } +} + +/// The last module: entered last, disabled first. +internal sealed class QualificationLastModule : ICheatEngineClientModule +{ + /// + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationLedger.Record("last.enabled"); + } + + /// + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + QualificationLedger.Record("last.disabling"); + } +} + +/// An activation-scoped resource whose disposal throws when the fault switch selects the resource cleanup. +internal sealed class QualificationScopedResource(FaultDecision fault) : IDisposable +{ + private bool _acquired; + private bool _disposed; + + /// Marks the resource as used by the activation. + internal void Acquire() + { + _acquired = true; + QualificationLedger.Record("resource.acquired"); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (fault.FaultsResourceCleanup) + { + InvalidOperationException failure = new("The qualification fault switch selected the resource cleanup."); + QualificationLedger.Record("resource.dispose.threw", failure); + throw failure; + } + + QualificationLedger.Record(_acquired ? "resource.disposed" : "resource.disposed.unused"); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.Experimental.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.Experimental.cs new file mode 100644 index 0000000..1cec079 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.Experimental.cs @@ -0,0 +1,722 @@ +using System.Collections.Immutable; +using System.Globalization; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +using LivePlugin.Qualification.Harness; + +// The scenarios of the experimental value scans (CECLIENT5001), allocations (CECLIENT5002), instructions (CECLIENT5003) +// and Auto Assembler patches (CECLIENT5004); the source is compiled standalone against the packed packages. +#pragma warning disable CECLIENT5001, CECLIENT5002, CECLIENT5003, CECLIENT5004 + +namespace LivePlugin.Qualification; + +/// +/// The value scan (Q25, Q26), allocation (Q30.a, Q30.b), instruction (Q32) and Auto Assembler patch (Q35) scenarios. +/// Every step that changes the target or Cheat Engine state runs behind and writes only +/// inside the declared scratch region; the leases they create are kept in so a +/// later step, after a target change or a re-enable, reports what the Client made of them. +/// +internal static partial class QualificationScenarios +{ + /// The scratch slots of the value scans: an int32 marker, then a float and a double for Q25's decimals. + private const int ValueScanOffset = 1536; + + private const int SingleOffset = 1552; + private const int DoubleOffset = 1560; + + /// The scratch window of the instruction round trip (Q32). + private const int InstructionOffset = 2048; + + private const int InstructionWindow = 64; + + /// How many value-scan matches the harness reads back; the scan range is the 4096-byte scratch region. + private const int ValueScanPageSize = 64; + + /// The value Q25 scans for with texts of several decimals (). + private const float SingleProbe = 3.14159f; + + private const double DoubleProbe = 3.14159; + + /// The symbol the benign Auto Assembler patch allocates and registers, and unregisters and frees on disable. + internal const string PatchSymbol = NamePrefix + "aa_memory"; + + private const string PatchName = NamePrefix + "aa_patch"; + + /// The benign Q35 patch: an allocation and a symbol, both undone by its [DISABLE] section. + internal const string BenignPatchSource = "// " + CapturingLoggerProvider.ScriptMarker + "\n" + + "[ENABLE]\n" + + "alloc(" + PatchSymbol + ",64)\n" + + "registersymbol(" + PatchSymbol + ")\n" + + PatchSymbol + ":\n" + + "dd 0\n" + + "[DISABLE]\n" + + "unregistersymbol(" + PatchSymbol + ")\n" + + "dealloc(" + PatchSymbol + ")\n"; + + /// The failing Q35 variant: it writes to a label nothing defines, so Cheat Engine refuses it. + internal const string FailingPatchSource = "// " + CapturingLoggerProvider.ScriptMarker + "\n" + + "[ENABLE]\n" + + NamePrefix + "aa_undefined_label:\n" + + "db 90\n" + + "[DISABLE]\n"; + + /// + /// One value-scan step (Q25, Q26) on the scratch region: first writes the int32 marker + /// into its slot and runs a first scan for it, next writes the new marker and runs + /// a next scan, reset resets the session, decimals checks Cheat Engine's tolerance for the decimal + /// text of FromSingle and FromDouble, state reads the session, release releases it + /// (also after a target change) and create-unidentified checks that no session is created on a file opened + /// as a process. + /// + internal static string ValueScan(string action, string symbolName, long value) + { + const string Function = "value_scan"; + return action switch + { + "state" => Guarded(Function, static observation => DescribeValueScan(observation.String("action", "state"))), + "release" => RunMutating(Function, static (observation, active, processId) => + ReleaseValueScan(observation.String("action", "release"), active, processId), MutationScope.OwnedResource), + "create-unidentified" => RunMutating(Function, static (observation, active, _) => + CreateUnidentifiedValueScan(observation.String("action", "create-unidentified"), active), + MutationScope.FileAsProcessTarget), + "first" or "next" or "reset" or "decimals" => RunMutating(Function, (observation, active, processId) => + RunValueScan(observation.String("action", action), active, processId, action, symbolName, value)), + _ => Guarded(Function, static observation => + observation.Boolean("ok", false).String("refusal", "UnknownAction").Complete()) + }; + } + + /// + /// One allocation step (Q30.a, Q30.b) under a scenario name: allocate makes bytes + /// on the authorized target and checks the region through the inspection API, state reads the lease, + /// release releases it (also after a target change, when the Client must refuse to free anything) and + /// allocate-unidentified checks that nothing is allocated on a file opened as a process. + /// + internal static string Allocation(string action, string name, long size) + { + const string Function = "allocation"; + if (!name.StartsWith(NamePrefix, StringComparison.Ordinal)) + { + return Guarded(Function, static observation => + observation.Boolean("ok", false).String("refusal", "NameNotHarness").Complete()); + } + + return action switch + { + "allocate" => RunMutating(Function, (observation, active, _) => + Allocate(observation.String("action", action).String("name", name), active, name, size)), + "state" => Guarded(Function, observation => + DescribeAllocation(observation.String("action", action).String("name", name), name)), + "release" => RunMutating(Function, (observation, active, processId) => + ReleaseAllocation(observation.String("action", action).String("name", name), active, processId, name), + MutationScope.OwnedResource), + "allocate-unidentified" => RunMutating(Function, (observation, active, _) => + AllocateUnidentified(observation.String("action", action), active, size), + MutationScope.FileAsProcessTarget), + _ => Guarded(Function, static observation => + observation.Boolean("ok", false).String("refusal", "UnknownAction").Complete()) + }; + } + + /// + /// The instruction profile of the selected target (Q32): the bytes Cheat Engine assembles for a fixed instruction + /// list at the scratch window, both jump encodings, and a round trip that writes mov eax,1; ret into the + /// window, disassembles it, measures it, finds the previous instruction, and restores the window. + /// + internal static string Instructions(string symbolName) + { + return RunMutating("instructions", (observation, active, processId) => + { + if (!TryResolveDeclaredScratch(observation, active, symbolName, processId, out Address scratch)) + { + return observation.Complete(); + } + + Address window = scratch + InstructionOffset; + if (!GuardWrite(observation, processId, window, InstructionWindow) || + !TryReadOriginal(observation, active, window, InstructionWindow, out ImmutableArray original)) + { + return observation.Complete(); + } + + int bitness = active.Client.Processes.TryRefresh(out ProcessSnapshot process, out _) ? process.Bitness.Bytes : 0; + string framePointer = bitness == 4 ? "ebp" : "rbp"; + string jumpTarget = HexOperand(window.Value + 0x20, bitness); + IAssemblyClient assembly = active.Client.Assembly; + observation.Number("bitnessBytes", bitness).BeginArray("assembled"); + foreach ((string id, string text, InstructionEncodingPreference preference) in + (ReadOnlySpan<(string, string, InstructionEncodingPreference)>) + [ + ("nop", "nop", InstructionEncodingPreference.None), + ("ret", "ret", InstructionEncodingPreference.None), + ("int3", "int3", InstructionEncodingPreference.None), + ("mov-eax-1", "mov eax,1", InstructionEncodingPreference.None), + ("push-frame-pointer", "push " + framePointer, InstructionEncodingPreference.None), + ("jmp-short", "jmp " + jumpTarget, InstructionEncodingPreference.Short), + ("jmp-long", "jmp " + jumpTarget, InstructionEncodingPreference.Long) + ]) + { + bool assembled = assembly.TryAssemble(new AssemblyInstructionRequest(window, text, preference), + out ImmutableArray bytes, out CheatEngineFailure failure); + observation.BeginItem().String("id", id).Boolean("assembled", assembled) + .String("bytes", assembled ? Convert.ToHexString(bytes.AsSpan()) : null); + if (!assembled) + { + observation.Failure("failure", failure); + } + + observation.EndObject(); + } + + observation.EndArray(); + return InstructionRoundTrip(observation, active, window, original); + }); + } + + /// + /// One Auto Assembler step (Q35, Q44), available only when the activation composed + /// EnableAutoAssemblerPatches() (an authorized run with CECLIENT_QUALIFICATION_ENABLE_AA=1): + /// check checks the benign script, apply applies the benign or the failing variant, + /// state reads the patch lease and release disables it (also after a target change). + /// + internal static string AutoAssemblerPatch(string action, string variant) + { + const string Function = "aa_patch"; + return action switch + { + "state" => Guarded(Function, static observation => + QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active) + ? DescribePatch(observation.String("action", "state"), active) + : Inactive(observation)), + "release" => RunMutating(Function, static (observation, active, _) => + ReleasePatch(observation.String("action", "release"), active), MutationScope.OwnedResource), + "check" or "apply" => RunMutating(Function, (observation, active, processId) => + RunPatch(observation.String("action", action).String("variant", variant), active, processId, action, + variant)), + _ => Guarded(Function, static observation => + observation.Boolean("ok", false).String("refusal", "UnknownAction").Complete()) + }; + } + + private static string RunValueScan(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, string action, string symbolName, long value) + { + if (!TryResolveDeclaredScratch(observation, active, symbolName, processId, out Address scratch)) + { + return observation.Complete(); + } + + if (action == "decimals") + { + return ValueScanDecimals(observation, active, processId, scratch); + } + + Address slot = scratch + ValueScanOffset; + QualificationSession.ValueScanState? state = QualificationSession.ValueScan; + if (action == "reset") + { + if (state is null) + { + return observation.Boolean("ok", false).String("refusal", "NoSession").Complete(); + } + + bool reset = state.Session.TryReset(out CheatEngineFailure resetFailure); + if (!reset) + { + observation.Failure("failure", resetFailure); + } + + return DescribeValueScan(observation.Boolean("reset", reset)); + } + + int marker = unchecked((int) value); + if (!GuardWrite(observation, processId, slot, sizeof(int))) + { + return observation.Complete(); + } + + if (action == "first") + { + if (state is not null && !state.Session.IsReleased) + { + return observation.Boolean("ok", false).String("refusal", "SessionAlreadyCreated").Complete(); + } + + if (!TryReadOriginal(observation, active, slot, sizeof(int), out ImmutableArray original)) + { + return observation.Complete(); + } + + if (!active.Client.ValueScans.TryCreateSession(out IValueScanSession? session, + out CheatEngineFailure createFailure)) + { + return observation.Boolean("ok", false).Failure("createFailure", createFailure).Complete(); + } + + state = new QualificationSession.ValueScanState(session, slot.Value, [.. original]); + QualificationSession.ValueScan = state; + } + else if (state is null) + { + return observation.Boolean("ok", false).String("refusal", "NoSession").Complete(); + } + + QualificationSession.Logs.DeclareSensitive(marker.ToString(CultureInfo.InvariantCulture)); + if (!active.Client.Memory.TryWritePrimitive(slot, marker, out CheatEngineFailure writeFailure)) + { + return observation.Boolean("ok", false).Failure("writeFailure", writeFailure).Complete(); + } + + ValueScanValue scanned = ValueScanValue.FromInt32(marker); + CheatEngineFailure scanFailure; + bool scannedOk = action == "first" + ? state.Session.TryFirstScan(ValueScanFirstRequest.Exact(scanned).WithRange(scratch, scratch + ScratchLength), + out scanFailure) + : state.Session.TryNextScan(ValueScanNextRequest.Exact(scanned), out scanFailure); + observation.Boolean("scanned", scannedOk); + if (!scannedOk) + { + observation.Failure("scanFailure", scanFailure); + } + + WriteScanPage(observation, state.Session, slot); + return DescribeValueScan(observation); + } + + // Q25: Cheat Engine compares a float scan with the decimals of its text (rtRounded). Two rules fit its Lua + // documentation, and both find the probe 3.14159 by its own 5 decimals, by 3.14 and by 3, and not by 3.2 or 3.15: + // those cases carry an expectation. The 3-decimal text 3.142 is the discriminating case, recorded without one: + // ordinary rounding to 3 decimals (3.14159 rounds to 3.142) finds the probe, while the range the documentation + // states ("3" matches 3.0 to 3.4999, the text up to half a unit above it) stops below it. The evaluator names the + // rule the host applied, and ValueScanValue's remarks follow the recorded run. + private static string ValueScanDecimals(QualificationObservation observation, + QualificationSession.ActiveClient active, int processId, Address scratch) + { + Address singleSlot = scratch + SingleOffset; + Address doubleSlot = scratch + DoubleOffset; + if (!GuardWrite(observation, processId, singleSlot, 16) || + !TryReadOriginal(observation, active, singleSlot, 16, out ImmutableArray original)) + { + return observation.Complete(); + } + + bool written = active.Client.Memory.TryWritePrimitive(singleSlot, SingleProbe, out CheatEngineFailure failure) & + active.Client.Memory.TryWritePrimitive(doubleSlot, DoubleProbe, out _); + if (!written) + { + Restore(observation, active, singleSlot, original); + return observation.Boolean("ok", false).Failure("writeFailure", failure).Complete(); + } + + if (!active.Client.ValueScans.TryCreateSession(out IValueScanSession? session, + out CheatEngineFailure createFailure)) + { + Restore(observation, active, singleSlot, original); + return observation.Boolean("ok", false).Failure("createFailure", createFailure).Complete(); + } + + observation.BeginArray("cases"); + // A null expectation marks the discriminating case (3.142), whose result names the rounding rule. + foreach ((ValueScanValue scanned, Address slot, int decimals, bool? expectedFound) in + (ReadOnlySpan<(ValueScanValue, Address, int, bool?)>) + [ + (ValueScanValue.FromSingle(SingleProbe, 5), singleSlot, 5, true), + (ValueScanValue.FromSingle(SingleProbe, 3), singleSlot, 3, null), + (ValueScanValue.FromSingle(SingleProbe, 2), singleSlot, 2, true), + (ValueScanValue.FromSingle(SingleProbe, 0), singleSlot, 0, true), + (ValueScanValue.FromSingle(3.2f, 1), singleSlot, 1, false), + (ValueScanValue.FromSingle(3.15f, 2), singleSlot, 2, false), + (ValueScanValue.FromDouble(DoubleProbe, 5), doubleSlot, 5, true), + (ValueScanValue.FromDouble(DoubleProbe, 3), doubleSlot, 3, null), + (ValueScanValue.FromDouble(DoubleProbe, 2), doubleSlot, 2, true), + (ValueScanValue.FromDouble(DoubleProbe, 0), doubleSlot, 0, true), + (ValueScanValue.FromDouble(3.2, 1), doubleSlot, 1, false), + (ValueScanValue.FromDouble(3.15, 2), doubleSlot, 2, false) + ]) + { + bool scannedOk = session.TryFirstScan( + ValueScanFirstRequest.Exact(scanned).WithRange(scratch, scratch + ScratchLength), out CheatEngineFailure scanFailure); + observation.BeginItem() + .String("type", scanned.ValueType.ToString()) + .String("text", scanned.Text) + .Number("decimals", decimals) + .Boolean("discriminating", expectedFound is null) + .OptionalBoolean("expectedFound", expectedFound) + .Boolean("scanned", scannedOk); + if (scannedOk && session.TryRead(new ValueScanReadRequest(0, ValueScanPageSize), out ValueScanPage page, out _)) + { + observation.Boolean("found", page.Matches.Any(match => match.Address == slot)); + } + else if (!scannedOk) + { + observation.Failure("scanFailure", scanFailure); + } + + observation.EndObject(); + session.TryReset(out _); + } + + LeaseReleaseOutcome released = session.Release(); + Restore(observation.EndArray(), active, singleSlot, original); + return WriteReleaseOutcome(observation, "sessionRelease", released).Boolean("ok", true).Complete(); + } + + private static void WriteScanPage(QualificationObservation observation, IValueScanSession session, Address slot) + { + if (!session.TryRead(new ValueScanReadRequest(0, ValueScanPageSize), out ValueScanPage page, + out CheatEngineFailure failure)) + { + observation.Failure("readFailure", failure); + return; + } + + observation.BeginObject("results") + .Number("resultCount", (long) Math.Min(page.ResultCount, long.MaxValue)) + .Number("read", page.Matches.Length) + .Boolean("containsMarker", page.Matches.Any(match => match.Address == slot)) + .EndObject(); + } + + private static string DescribeValueScan(QualificationObservation observation) + { + if (QualificationSession.ValueScan is not { } state) + { + return observation.Boolean("ok", false).String("refusal", "NoSession").Complete(); + } + + IValueScanSession session = state.Session; + observation.BeginObject("session") + .String("state", session.State.ToString()) + .String("invalidation", session.Invalidation.ToString()) + .Boolean("released", session.IsReleased) + .EndObject(); + WriteLastRelease(observation, session.LastReleaseOutcome); + return observation.Boolean("ok", true).Complete(); + } + + private static string ReleaseValueScan(QualificationObservation observation, + QualificationSession.ActiveClient active, int processId) + { + if (QualificationSession.ValueScan is not { } state) + { + return observation.Boolean("ok", false).String("refusal", "NoSession").Complete(); + } + + LeaseReleaseOutcome released = state.Session.Release(); + WriteReleaseOutcome(observation, "release", released); + + // The marker slot is restored only in the authorized target the session was created in. + Address slot = new(state.Slot); + if (QualificationWriteGuard.Evaluate(QualificationSession.Authorization, processId, + QualificationSession.Declaration, state.Slot, state.OriginalBytes.Length) == WriteRefusal.None) + { + Restore(observation, active, slot, [.. state.OriginalBytes]); + } + + return DescribeValueScan(observation); + } + + private static string CreateUnidentifiedValueScan(QualificationObservation observation, + QualificationSession.ActiveClient active) + { + bool created = active.Client.ValueScans.TryCreateSession(out IValueScanSession? session, + out CheatEngineFailure failure); + observation.Boolean("created", created); + if (session is not null) + { + // Unexpected: a session on a file opened as a process. It is released at once and reported. + WriteReleaseOutcome(observation, "unexpectedSessionRelease", session.Release()); + } + else + { + observation.Failure("failure", failure); + } + + return observation.Boolean("ok", true).Complete(); + } + + private static string Allocate(QualificationObservation observation, QualificationSession.ActiveClient active, + string name, long size) + { + if (QualificationSession.TryGetAllocation(name, out ITargetMemoryLease? previous) && !previous.IsReleased) + { + return observation.Boolean("ok", false).String("refusal", "AllocationAlreadyHeld").Complete(); + } + + if (size is <= 0 or > 65_536) + { + return observation.Boolean("ok", false).String("refusal", "SizeOutOfRange").Complete(); + } + + if (!active.Client.Allocations.TryAllocate(new AllocationRequest(size), out ITargetMemoryLease? lease, + out CheatEngineFailure failure)) + { + return observation.Boolean("ok", false).Failure("failure", failure).Complete(); + } + + QualificationSession.KeepAllocation(name, lease); + QualificationSession.Logs.DeclareSensitive(QualificationObservation.Hex(lease.Address.Value)); + observation.Boolean("allocated", true).Number("size", lease.Size).String("protection", lease.Protection.ToString()) + .Number("selectionEpoch", lease.SelectionEpoch); + WriteRegion(observation, active, lease.Address, "region"); + return observation.Boolean("ok", true).Complete(); + } + + private static string DescribeAllocation(QualificationObservation observation, string name) + { + if (!QualificationSession.TryGetAllocation(name, out ITargetMemoryLease? lease)) + { + return observation.Boolean("ok", false).String("refusal", "NoAllocation").Complete(); + } + + observation.BeginObject("lease") + .Boolean("released", lease.IsReleased) + .Boolean("requiresManualRecovery", lease.RequiresManualRecovery) + .Number("selectionEpoch", lease.SelectionEpoch) + .Number("size", lease.Size) + .EndObject(); + WriteLastRelease(observation, lease.LastReleaseOutcome); + if (QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active) && + active.Client.Processes.TryRefresh(out ProcessSnapshot process, out _)) + { + observation.Number("currentSelectionEpoch", process.SelectionEpoch) + .Number("currentProcessId", process.Id.Value); + } + + return observation.Boolean("ok", true).Complete(); + } + + private static string ReleaseAllocation(QualificationObservation observation, + QualificationSession.ActiveClient active, int processId, string name) + { + if (!QualificationSession.TryGetAllocation(name, out ITargetMemoryLease? lease)) + { + return observation.Boolean("ok", false).String("refusal", "NoAllocation").Complete(); + } + + bool releasedBefore = lease.IsReleased; + WriteReleaseOutcome(observation.Boolean("releasedBefore", releasedBefore), "release", lease.Release()); + observation.Boolean("requiresManualRecovery", lease.RequiresManualRecovery) + .Boolean("onAuthorizedTarget", QualificationSession.Authorization.Allows(processId)); + + // Only in the authorized target does the region tell whether the memory was freed. + if (QualificationSession.Authorization.Allows(processId)) + { + WriteRegion(observation, active, lease.Address, "regionAfterRelease"); + } + + return observation.Boolean("ok", true).Complete(); + } + + private static string AllocateUnidentified(QualificationObservation observation, + QualificationSession.ActiveClient active, long size) + { + bool allocated = active.Client.Allocations.TryAllocate(new AllocationRequest(Math.Clamp(size, 1, 4096)), + out ITargetMemoryLease? lease, out CheatEngineFailure failure); + observation.Boolean("allocated", allocated); + if (lease is not null) + { + // Unexpected: an allocation on a file opened as a process. It is released at once and reported. + WriteReleaseOutcome(observation, "unexpectedAllocationRelease", lease.Release()); + } + else + { + observation.Failure("failure", failure); + } + + return observation.Boolean("ok", true).Complete(); + } + + private static void WriteRegion(QualificationObservation observation, QualificationSession.ActiveClient active, + Address address, string name) + { + if (!active.Client.Inspection.TryGetMemoryRegion(address, out MemoryRegionInfo region, + out CheatEngineFailure failure)) + { + observation.Failure(name + "Failure", failure); + return; + } + + observation.BeginObject(name) + .String("state", region.State.ToString()) + .String("type", region.Type.ToString()) + .String("protection", region.Protection.ToString()) + .Boolean("startsAtAllocation", region.BaseAddress == address) + .EndObject(); + } + + private static string InstructionRoundTrip(QualificationObservation observation, + QualificationSession.ActiveClient active, Address window, ImmutableArray original) + { + IAssemblyClient assembly = active.Client.Assembly; + bool assembledMove = assembly.TryAssemble(new AssemblyInstructionRequest(window, "mov eax,1"), + out ImmutableArray move, out CheatEngineFailure failure); + bool assembledReturn = assembly.TryAssemble(new AssemblyInstructionRequest(window + move.Length, "ret"), + out ImmutableArray ret, out _); + if (!assembledMove || !assembledReturn) + { + return observation.Boolean("ok", false).Failure("roundTripAssembleFailure", failure).Complete(); + } + + byte[] code = [.. move, .. ret]; + if (!active.Client.Memory.TryWriteBytes(new MemoryBytesWriteRequest(window, code), out CheatEngineFailure writeFailure)) + { + return observation.Boolean("ok", false).Failure("roundTripWriteFailure", writeFailure).Complete(); + } + + bool disassembled = assembly.TryDisassemble(window, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure disassembleFailure); + bool measured = assembly.TryGetInstructionLength(window, out int length, out _); + bool previousFound = assembly.TryGetPreviousInstructionAddress(window + move.Length, out Address previous, + out _); + Restore(observation, active, window, original); + observation.BeginObject("roundTrip") + .String("bytes", Convert.ToHexString(code)) + .Boolean("disassembled", disassembled) + .String("opcode", disassembled ? instruction.Opcode : null) + .String("extra", disassembled ? instruction.Extra : null) + .Number("snapshotLength", disassembled ? instruction.Length : 0) + .Boolean("bytesEqual", disassembled && instruction.Bytes.AsSpan().SequenceEqual(move.AsSpan())) + .Boolean("measured", measured) + .Number("length", measured ? length : 0) + .Boolean("previousIsWindow", previousFound && previous == window); + if (!disassembled) + { + observation.Failure("disassembleFailure", disassembleFailure); + } + + return observation.EndObject().Boolean("ok", true).Complete(); + } + + private static string HexOperand(ulong address, int bitness) + { + // A leading digit keeps Cheat Engine's assembler from reading the operand as a symbol name. + return address.ToString(bitness == 4 ? "X8" : "X16", CultureInfo.InvariantCulture); + } + + private static string RunPatch(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, string action, string variant) + { + if (active.Services.GetService(typeof(IAutoAssemblerClient)) is not IAutoAssemblerClient patches) + { + return observation.Boolean("ok", false).String("refusal", "AutoAssemblerNotEnabled") + .Boolean("optInRequested", QualificationSession.Inputs.EnableAutoAssembler).Complete(); + } + + string? source = variant switch + { + "benign" => BenignPatchSource, + "failing" => FailingPatchSource, + _ => null + }; + if (source is null) + { + return observation.Boolean("ok", false).String("refusal", "UnknownVariant").Complete(); + } + + QualificationSession.Logs.DeclareSensitive(PatchSymbol); + AutoAssemblerScript script = new(source, PatchName); + if (action == "check") + { + bool checkedOk = patches.TryCheck(script, out AutoAssemblerCheckResult result, out CheatEngineFailure failure); + observation.Boolean("checked", checkedOk); + if (checkedOk) + { + observation.Boolean("accepted", result.IsAccepted).Boolean("hostMessages", result.HostMessages is not null) + .Boolean("hostMessagesTruncated", result.HostMessagesTruncated); + } + else + { + observation.Failure("failure", failure); + } + + return observation.Boolean("ok", true).Complete(); + } + + if (QualificationSession.Patch is { IsReleased: false }) + { + return observation.Boolean("ok", false).String("refusal", "PatchAlreadyApplied").Complete(); + } + + // The target was selected by the driver through Cheat Engine itself (openProcess), never through the Client. + long selectionEpoch = active.Client.Processes.TryRefresh(out ProcessSnapshot process, out _) + ? process.SelectionEpoch + : 0; + observation.Number("processId", processId).Number("selectionEpochBeforeApply", selectionEpoch); + bool applied = patches.TryApplyPatch(script, out IAutoAssemblerPatchLease? lease, out CheatEngineFailure applyFailure); + observation.Boolean("applied", applied); + if (lease is null) + { + return observation.Failure("failure", applyFailure).Boolean("ok", true).Complete(); + } + + QualificationSession.Patch = lease; + return DescribePatch(observation, active); + } + + private static string DescribePatch(QualificationObservation observation, QualificationSession.ActiveClient active) + { + if (QualificationSession.Patch is not { } lease) + { + return observation.Boolean("ok", false).String("refusal", "NoPatch").Complete(); + } + + observation.BeginObject("lease") + .Boolean("canDisable", lease.CanDisable) + .Boolean("released", lease.IsReleased) + .Boolean("requiresManualRecovery", lease.RequiresManualRecovery) + .Boolean("appliedAfterTargetChange", lease.AppliedAfterTargetChange) + .Boolean("hostWarnings", lease.HostWarnings is not null) + .Boolean("hostWarningsTruncated", lease.HostWarningsTruncated) + .Number("selectionEpoch", lease.SelectionEpoch) + .EndObject(); + WriteLastRelease(observation, lease.LastReleaseOutcome); + bool resolves = active.Client.Inspection.TryResolveAddress(new SymbolExpression(PatchSymbol), + AddressResolutionMode.Default, out _, out _); + return observation.Boolean("symbolResolves", resolves).Boolean("ok", true).Complete(); + } + + private static string ReleasePatch(QualificationObservation observation, QualificationSession.ActiveClient active) + { + if (QualificationSession.Patch is not { } lease) + { + return observation.Boolean("ok", false).String("refusal", "NoPatch").Complete(); + } + + WriteReleaseOutcome(observation, "release", lease.Release()); + return DescribePatch(observation, active); + } + + private static QualificationObservation WriteReleaseOutcome(QualificationObservation observation, string name, + LeaseReleaseOutcome outcome) + { + return observation.BeginObject(name) + .String("kind", outcome.Kind.ToString()) + .String("hostEffect", outcome.HostEffect.ToString()) + .Boolean("complete", outcome.IsComplete) + .Boolean("retryable", outcome.IsRetryable) + .Boolean("requiresManualRecovery", outcome.RequiresManualRecovery) + .EndObject(); + } + + private static void WriteLastRelease(QualificationObservation observation, LeaseReleaseOutcome? outcome) + { + if (outcome is { } last) + { + WriteReleaseOutcome(observation, "lastRelease", last); + } + else + { + observation.String("lastRelease", null); + } + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.Host.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.Host.cs new file mode 100644 index 0000000..77077c8 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.Host.cs @@ -0,0 +1,244 @@ +using System.Globalization; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; + +using LivePlugin.Qualification.Harness; + +namespace LivePlugin.Qualification; + +/// +/// The worker admission (Q19), table file (Q34) and Lua marshalling (CRIT-07) scenarios. +/// +internal static partial class QualificationScenarios +{ + /// The table files the harness saves and loads below its table root carry this name prefix. + internal const string TableFilePrefix = NamePrefix + "table"; + + private static readonly Lock WorkerGate = new(); + private static WorkerProbe? _worker; + + /// + /// Q19, in two steps so that Cheat Engine's main thread stays free while the worker waits for it: start + /// starts a worker that calls IProcessClient.TryRefresh (the Client marshals it to the main thread) and then + /// calls Register of a generated probe module directly, which CheatEngine.SDK must refuse off the main thread + /// (InvalidState, NotStarted, nothing published). result reports "pending":true until the + /// worker ended, then what both calls did; a probe that was registered after all is released there, on the main + /// thread. + /// + internal static string WorkerAdmission(string action) + { + const string Function = "worker_admission"; + return action switch + { + "start" => RunMutating(Function, static (observation, active, _) => StartWorker(observation, active)), + "result" => Guarded(Function, static observation => ReadWorker(observation)), + _ => Guarded(Function, static observation => + observation.Boolean("ok", false).String("refusal", "UnknownAction").Complete()) + }; + } + + /// + /// Saves the current table (Q34) as below the table root the runner configured, or, + /// with set, in the root's parent folder, where the Client must refuse it before + /// any Cheat Engine call. + /// + internal static string TableSave(string fileName, long outsideRoot) + { + return RunMutating("table_save", (observation, active, _) => + { + if (!TryTableFile(observation, fileName, outsideRoot != 0, out TrustedTableFile file)) + { + return observation.Complete(); + } + + bool saved = active.Client.Tables.TrySaveTable(new TableSaveRequest(file), out CheatEngineFailure failure); + observation.Boolean("outsideRoot", outsideRoot != 0).Boolean("saved", saved) + .Boolean("fileExists", File.Exists(file.FullPath)); + if (!saved) + { + observation.Failure("failure", failure); + } + + return observation.Boolean("ok", true).Complete(); + }); + } + + /// + /// Loads from the table root (Q34). A load that reached Cheat Engine ends the validity + /// of every record id handed out before, which table_probe then checks. + /// + internal static string TableLoad(string fileName) + { + return RunMutating("table_load", (observation, active, _) => + { + if (!TryTableFile(observation, fileName, false, out TrustedTableFile file)) + { + return observation.Complete(); + } + + bool loaded = active.Client.Tables.TryLoadTrustedTable(new TableLoadRequest(file), out CheatEngineFailure failure); + observation.Boolean("loaded", loaded); + if (!loaded) + { + observation.Failure("failure", failure); + } + + return observation.Boolean("ok", true).Complete(); + }); + } + + /// + /// CRIT-07: returns the integer CheatEngine.SDK marshalled. The driver calls it with integers and with floats around + /// 2^53, which the SDK must refuse with a Lua error instead of rounding. + /// + internal static string IntegerEcho(long value) + { + return Guarded("integer_echo", observation => observation.Boolean("ok", true) + .String("value", value.ToString(CultureInfo.InvariantCulture)).Complete()); + } + + /// CRIT-07: returns the target address CheatEngine.SDK marshalled, with the same 2^53 rule as an integer. + internal static string AddressEcho(nuint address) + { + return Guarded("address_echo", observation => observation.Boolean("ok", true) + .String("value", ((ulong) address).ToString(CultureInfo.InvariantCulture)).Complete()); + } + + private static string StartWorker(QualificationObservation observation, QualificationSession.ActiveClient active) + { + lock (WorkerGate) + { + if (_worker is { Task.IsCompleted: false }) + { + return observation.Boolean("ok", false).String("refusal", "WorkerRunning").Complete(); + } + + WorkerProbe probe = new(Environment.CurrentManagedThreadId); + probe.Start(active); + _worker = probe; + } + + return observation.Boolean("started", true).Boolean("ok", true).Complete(); + } + + private static string ReadWorker(QualificationObservation observation) + { + WorkerProbe? probe; + lock (WorkerGate) + { + probe = _worker; + } + + if (probe is null) + { + return observation.Boolean("ok", false).String("refusal", "NoWorker").Complete(); + } + + if (!probe.Task.IsCompleted) + { + return observation.Boolean("pending", true).Boolean("ok", true).Complete(); + } + + return probe.Describe(observation.Boolean("pending", false)).Boolean("ok", true).Complete(); + } + + private static bool TryTableFile(QualificationObservation observation, string fileName, bool outsideRoot, + out TrustedTableFile file) + { + file = default; + if (QualificationSession.Inputs.TableRoot is not { } root) + { + observation.Boolean("ok", false).String("refusal", "NoTableRoot"); + return false; + } + + if (!fileName.StartsWith(TableFilePrefix, StringComparison.Ordinal) || + !fileName.EndsWith(".CT", StringComparison.Ordinal) || + fileName.AsSpan().IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + observation.Boolean("ok", false).String("refusal", "FileNameNotHarness"); + return false; + } + + string? directory = outsideRoot ? Path.GetDirectoryName(root) : root; + if (directory is null) + { + observation.Boolean("ok", false).String("refusal", "NoParentFolder"); + return false; + } + + file = new TrustedTableFile(Path.Combine(directory, fileName)); + return true; + } + + /// The Q19 worker: its two calls and the thread they ran on, written by the worker and read on the main thread. + private sealed class WorkerProbe(int mainThreadId) + { + private readonly QualificationWorkerProbeModule _module = new(); + private CheatEngineFailure _refreshFailure; + private CheatEngineFailure _registerFailure; + private string? _registerException; + private bool _registered; + private bool _refreshed; + private int _workerThreadId; + + internal Task Task + { + get; + private set; + } = Task.CompletedTask; + + internal void Start(QualificationSession.ActiveClient active) + { + Task = Task.Run(() => + { + _workerThreadId = Environment.CurrentManagedThreadId; + _refreshed = active.Client.Processes.TryRefresh(out ProcessSnapshot _, out _refreshFailure); + try + { + _module.Register(); + _registered = true; + } + catch (CheatEngineClientException exception) + { + // A refused registration throws the exception of its failure's kind: InvalidState from a worker. + _registerFailure = exception.Failure; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + _registerException = exception.GetType().Name; + } + }); + } + + internal QualificationObservation Describe(QualificationObservation observation) + { + observation.Boolean("offMainThread", _workerThreadId != 0 && _workerThreadId != mainThreadId) + .BeginObject("marshalledCall").Boolean("succeeded", _refreshed); + if (!_refreshed) + { + observation.Failure("failure", _refreshFailure); + } + + observation.EndObject().BeginObject("directRegister").Boolean("registered", _registered) + .String("exception", _registerException); + if (!_registered && _registerException is null) + { + observation.Failure("failure", _registerFailure); + } + + if (_registered) + { + // Unexpected: the probe published its global from a worker. It is released here, on the main thread. + LuaModuleReleaseOutcome released = _module.Unregister(); + observation.String("unexpectedRegistrationRelease", released.Kind.ToString()); + _registered = false; + } + + return observation.EndObject(); + } + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.cs new file mode 100644 index 0000000..bf84cc0 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationScenarios.cs @@ -0,0 +1,1106 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +using CheatEngine.Client; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +using LivePlugin.Qualification.Harness; + +namespace LivePlugin.Qualification; + +/// +/// The scenario bodies behind the harness Lua functions. Every Cheat Engine interaction goes through the public +/// CheatEngine.Client API of the current activation (ADR-01): Cheat Engine-level setup such as opening the target, +/// allocating the scratch region, changing the pointer size or redefining a symbol belongs to the scenario's driver +/// Lua, never to this plugin. Each function returns one bounded, redacted JSON observation +/// (); an exception is reported by its type name only. +/// +internal static partial class QualificationScenarios +{ + /// Every name the driver and the harness create in Cheat Engine starts with this prefix. + internal const string NamePrefix = "cheatengine_client_qualification_"; + + /// The size of the scratch region the driver allocates (alloc(<prefix>scratch,4096)). + internal const int ScratchLength = 4096; + + private const string BridgeFileName = "cheatengine-sdk-lua-bridge.dll"; + private const string RecordDescription = NamePrefix + "record"; + private const int ModuleLimit = 1024; + private const int DefaultResultLimit = 100_000; + private const int MaximumResultLimit = 1_000_000; + + // Offsets inside the scratch region, one slot per scenario kind so that no write overlaps another. + private const int BytesOffset = 0; + private const int Utf8Offset = 64; + private const int Utf16Offset = 256; + private const int Int32Offset = 512; + private const int UInt32Offset = 528; + private const int Int64Offset = 544; + private const int AddressOffset = 640; + private const int BatchOffset = 1024; + + private static readonly Lock RecordGate = new(); + private static int? _lastRecordId; + + /// Plugin identity, gate, fault switch, activation history and bridge hash (Q05, Q06, Q40, Q43). + internal static string Status() + { + return Guarded("status", static observation => + { + AuthorizationDecision gate = QualificationSession.Authorization; + FaultDecision fault = QualificationLedger.LastFault; + bool active = QualificationSession.TryGetActive(out QualificationSession.ActiveClient? current); + observation.Boolean("ok", true) + .BeginObject("gate").Boolean("allowed", gate.IsAllowed).String("denial", gate.Denial.ToString()).EndObject() + .BeginObject("fault").String("stage", fault.Stage.ToString()).String("reason", fault.Reason.ToString()) + .EndObject() + .BeginObject("plugin") + .String("name", QualificationPlugin.DisplayName) + .Number("pluginId", QualificationSession.PluginId) + .Boolean("active", active) + .Number("epoch", current?.Client.Epoch ?? 0) + .Number("enableAttempts", QualificationLedger.EnableAttempts) + .Number("activations", QualificationLedger.Activations) + .Number("lastEpoch", QualificationLedger.LastEpoch) + .Number("previousEpoch", QualificationLedger.PreviousEpoch) + .EndObject(); + + observation.BeginArray("assemblies"); + Identity(observation, "plugin", typeof(QualificationPlugin).Assembly); + Identity(observation, "clientHosting", typeof(CheatEngineClientPlugin).Assembly); + if (current is not null) + { + Identity(observation, "clientCore", current.Client.Memory.GetType().Assembly); + } + + if (typeof(CheatEngineClientPlugin).BaseType is { } sdkPlugin) + { + Identity(observation, "sdkHosting", sdkPlugin.Assembly); + } + + Identity(observation, "sdkEngine", typeof(Address).Assembly); + observation.EndArray(); + + string? bridge = BridgeSha256(); + observation.BeginObject("bridge").Boolean("present", bridge is not null).String("sha256", bridge).EndObject() + .Strings("ledger", QualificationLedger.Entries()) + .Number("ledgerDropped", QualificationLedger.Dropped); + return observation.Complete(); + }); + } + + /// + /// The Client's process snapshot, the process id first, and its runtime snapshot: the host facts (Cheat Engine file + /// version, operating system, Cheat Engine bitness), the target backend, architecture and bitness, and the pointer + /// size Cheat Engine is configured with (Q31, Q32, Q45). A fact the Client reports as unknown is written as + /// null, never derived. + /// + internal static string Runtime() + { + return Guarded("runtime", static observation => + { + if (!QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active)) + { + return Inactive(observation); + } + + if (!active.Client.Processes.TryRefresh(out ProcessSnapshot process, out CheatEngineFailure failure)) + { + return observation.Boolean("ok", false).Failure("failure", failure).Complete(); + } + + observation.BeginObject("process") + .Number("processId", process.Id.Value) + .String("backend", process.Backend.ToString()) + .String("targetArchitecture", process.Architecture.ToString()) + .Number("bitnessBytes", process.Bitness.Bytes) + .OptionalNumber("configuredPointerSizeBytes", process.ConfiguredPointerSizeBytes) + .OptionalBoolean("configuredPointerSizeDiffersFromBitness", + process.ConfiguredPointerSizeDiffersFromBitness) + .Number("selectionEpoch", process.SelectionEpoch) + .EndObject() + .Boolean("targetSelected", process.Id.Value != 0); + + if (!active.Client.Runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure)) + { + return observation.Failure("runtimeFailure", failure).Boolean("ok", false).Complete(); + } + + CheatEngineRuntimeVersionInfo version = snapshot.Version; + CheatEngineRuntimePlatformInfo platform = snapshot.Platform; + return observation.BeginObject("host") + .String("cheatEngineVersion", version.CheatEngineVersion?.ToString()) + .Boolean("onQualifiedCheatEngineLine", version.IsOnQualifiedCheatEngineLine) + .String("operatingSystem", platform.HostOperatingSystem.ToString()) + .OptionalNumber("cheatEngineBitnessBytes", + platform.CheatEngineBitness.IsKnown ? platform.CheatEngineBitness.Bytes : null) + .String("architecture", platform.HostArchitecture.ToString()) + .EndObject() + .BeginObject("sdk") + .String("packageVersion", version.SdkPackageVersion) + .Boolean("reviewedPackage", version.IsReviewedSdkPackage) + .EndObject() + .BeginObject("runtime") + .String("backend", platform.TargetBackend.ToString()) + .String("targetArchitecture", platform.TargetArchitecture.ToString()) + .Number("bitnessBytes", platform.TargetBitness.Bytes) + .String("targetAbi", platform.TargetAbi.ToString()) + .OptionalBoolean("targetIsAndroid", platform.TargetIsAndroid) + .Number("activationEpoch", snapshot.Epoch) + .EndObject() + .BeginObject("configuredPointerSize") + .Boolean("exposedByClient", true) + .OptionalNumber("bytes", platform.ConfiguredPointerSizeBytes) + .OptionalBoolean("differsFromBitness", platform.ConfiguredPointerSizeDiffersFromBitness) + .EndObject() + .Boolean("ok", true) + .Complete(); + }); + } + + /// + /// Availability and evidence gates of every Client capability (Q45: reading them changes nothing). With + /// = 0 it also reports the policy refusal of the capabilities that need an activation + /// opt-in (Q44): without EnableAutoAssemblerPatches or EnableUnsafeLuaExecution the Client registers + /// no IAutoAssemblerClient or IUnsafeLuaClient, so nothing can reach Cheat Engine, and the capability + /// reports a Missing policy gate. Neither form calls Cheat Engine beyond the runtime observations. + /// + internal static string Capabilities(long probeOnly) + { + return Guarded("capabilities", observation => + QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active) + ? WriteCapabilities(observation, active, policyRefusals: probeOnly == 0) + : Inactive(observation)); + } + + /// + /// Declares the scratch region the driver allocated in the authorized target, after verifying it through the Client + /// inspection API: a harness-prefixed symbol, resolving to the base of a committed, private, writable region that + /// holds bytes. Every later write must stay inside it. + /// + internal static string DeclareTarget(string symbolName) + { + return RunMutating("target_declare", (observation, active, processId) => + { + if (!TryResolveScratch(observation, active, symbolName, out Address scratch, out MemoryRegionInfo region)) + { + return observation.Complete(); + } + + bool committed = region.State == MemoryRegionState.Committed; + bool privateMemory = region.Type == MemoryRegionType.Private; + bool writable = (region.Protection & (MemoryProtection.ReadWrite | MemoryProtection.ExecuteReadWrite)) != 0; + ulong regionEnd = region.BaseAddress.Value + region.Size.Value; + bool fits = scratch.Value >= region.BaseAddress.Value && regionEnd >= region.BaseAddress.Value && + ScratchLength <= regionEnd - scratch.Value; + observation.BeginObject("region") + .Boolean("committed", committed) + .Boolean("private", privateMemory) + .Boolean("writable", writable) + .Boolean("holdsScratch", fits) + .EndObject(); + if (!(committed && privateMemory && writable && fits)) + { + return observation.Boolean("ok", false).String("refusal", "RegionNotWritableScratch").Complete(); + } + + QualificationSession.Declare(new TargetDeclaration(processId, + [new WritableRegion("scratch", scratch.Value, ScratchLength)])); + QualificationSession.Logs.DeclareSensitive(QualificationObservation.Hex(scratch.Value)); + return observation.Boolean("ok", true).Address("scratch", scratch.Value).Number("length", ScratchLength) + .Complete(); + }); + } + + /// + /// One Client AOB scan with its detailed outcome (Q27, Q28, Q29): result bounds, host and copy metrics, managed + /// allocations, and for a module scan an exactness check against the unfiltered scan. + /// + internal static string Aob(string pattern, string moduleName, long maxResults, long cancelAfterMs) + { + return Guarded("aob", observation => + { + if (!QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active)) + { + return Inactive(observation); + } + + if (!AobPattern.TryParse(pattern, out AobPattern aobPattern)) + { + return observation.Boolean("ok", false).String("refusal", "InvalidPattern").Complete(); + } + + QualificationSession.Logs.DeclareSensitive(pattern); + int limit = maxResults > 0 ? (int) Math.Min(maxResults, MaximumResultLimit) : DefaultResultLimit; + ModuleName? module = string.IsNullOrWhiteSpace(moduleName) ? null : new ModuleName(moduleName); + AobScanRequest request = new(aobPattern, limit, module); + using CancellationTokenSource? cancellation = cancelAfterMs > 0 + ? new CancellationTokenSource(TimeSpan.FromMilliseconds(cancelAfterMs)) + : null; + + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + long started = Stopwatch.GetTimestamp(); + PatternScanOutcome outcome = active.Client.Patterns.ScanDetailed(request, + cancellation?.Token ?? CancellationToken.None); + TimeSpan elapsed = Stopwatch.GetElapsedTime(started); + long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + + List matches = outcome.Result is { } result ? [.. result.Matches.Select(static match => match.Value)] : []; + bool truncated = outcome.Result?.IsTruncated ?? false; + observation.Boolean("ok", outcome.IsSuccess) + .BeginObject("request") + .Number("patternLength", aobPattern.ByteLength) + .Boolean("moduleFilter", module is not null) + .Number("limit", limit) + .Number("cancelAfterMs", cancelAfterMs) + .EndObject() + .BeginObject("route") + .String("scope", outcome.Metrics?.Scope.ToString()) + .String("hostOutcome", outcome.HostOutcome.ToString()) + .String("reason", outcome.RouteReason.ToString()) + .Boolean("targetIdentityVerified", outcome.TargetIdentityVerified) + .EndObject(); + if (outcome.Failure is { } cause) + { + observation.Failure("failure", cause); + } + + observation.Boolean("resultPublished", outcome.Result is not null) + .Boolean("truncated", truncated) + .Addresses("matches", matches) + .Number("elapsedMicroseconds", Microseconds(elapsed)) + .Number("managedAllocatedBytes", allocated); + WriteMetrics(observation, outcome.Metrics); + + // Truncation is proven by the Client's own counts: one more in-request address was examined than copied, or + // host rows were left unread. The copy cap is the Client's (IPatternScanner documents it), never a harness + // constant; copiedBelowLimit records that the cap, not the request, stopped the copy. + bool cancelled = outcome.Failure is { Kind: CheatEngineFailureKind.Cancelled }; + bool indeterminate = outcome.Failure is { Kind: CheatEngineFailureKind.IndeterminateHostResult }; + bool truncationProven = outcome.Metrics is { } counts && counts.MaterializedCount == matches.Count && + (counts.UnreadHostRowCount > 0 || + counts.ExaminedCount - counts.FilteredOutCount > (ulong) counts.MaterializedCount); + observation.BeginObject("checks") + .Boolean("truncationExplicit", outcome.IsSuccess && truncated && truncationProven && matches.Count <= limit) + .Boolean("copiedBelowLimit", outcome.IsSuccess && truncated && matches.Count < limit) + .Boolean("cancellationHonest", (cancelled && outcome.Result is null) || (outcome.IsSuccess && !truncated)) + .Boolean("noPrefixPublished", outcome.IsSuccess || outcome.Result is null) + .Boolean("notFoundReported", outcome.Failure is { Kind: CheatEngineFailureKind.NotFound }) + .Boolean("indeterminateReported", indeterminate) + .Boolean("globalZeroIsIndeterminate", + indeterminate && outcome.HostOutcome == PatternScanHostOutcomeKind.NoResult) + .Boolean("boundedZeroIsNoMatches", outcome.IsSuccess && matches.Count == 0 && + outcome.HostOutcome == PatternScanHostOutcomeKind.NoMatches) + .EndObject(); + + if (module is { } moduleFilter && outcome.IsSuccess && !truncated) + { + WriteModuleExactness(observation, active, aobPattern, moduleFilter, limit, matches); + } + + return observation.Complete(); + }); + } + + /// + /// Writes one boundary value or byte pattern into the declared scratch region through the Client memory API, + /// reads it back exactly, and restores the original bytes (Q20, Q21). + /// + internal static string MemoryRoundTrip(string kind, string symbolName) + { + return RunMutating("memory_roundtrip", (observation, active, processId) => + { + if (!TryResolveDeclaredScratch(observation, active, symbolName, processId, out Address scratch)) + { + return observation.Complete(); + } + + observation.String("kind", kind); + return kind switch + { + "bytes-with-nul" => BytesRoundTrip(observation, active, processId, scratch + BytesOffset, + [0x41, 0x00, 0x42, 0x00, 0x00, 0x43]), + "utf8-multibyte" => StringRoundTrip(observation, active, processId, scratch + Utf8Offset, + "Qualification éü 漢字 🙂", wide: false), + "utf16-with-nul" => StringRoundTrip(observation, active, processId, scratch + Utf16Offset, + "A\0Bé", wide: true), + "int32-minus-one" => Int32RoundTrip(observation, active, processId, scratch + Int32Offset, -1), + "uint32-max" => UInt32RoundTrip(observation, active, processId, scratch + UInt32Offset, uint.MaxValue), + "int64-limits" => Int64RoundTrip(observation, active, processId, scratch + Int64Offset), + "address-above-4gib" => AddressRoundTrip(observation, active, processId, scratch + AddressOffset), + _ => observation.Boolean("ok", false).String("refusal", "UnknownKind").Complete() + }; + }); + } + + /// + /// A four-element batch whose third address lies in the never-mapped null region (Q33): the detailed outcome must + /// expose two completed writes, the failed index and a partial effect, confirmed by reading the scratch back. + /// + internal static string MemoryBatchPartial(string symbolName, long invalidAddress) + { + return RunMutating("memory_batch_partial", (observation, active, processId) => + { + if (!TryResolveDeclaredScratch(observation, active, symbolName, processId, out Address scratch)) + { + return observation.Complete(); + } + + ulong invalid = unchecked((ulong) invalidAddress); + if (!QualificationWriteGuard.IsNeverMapped(invalid)) + { + return observation.Boolean("ok", false).String("refusal", "InvalidAddressMayBeMapped").Complete(); + } + + Address first = scratch + BatchOffset; + Address second = scratch + (BatchOffset + 4); + Address fourth = scratch + (BatchOffset + 12); + if (!GuardWrite(observation, processId, first, 16)) + { + return observation.Complete(); + } + + if (!active.Client.Memory.TryReadBytes(new MemoryBytesReadRequest(first, 16), out ImmutableArray original, + out CheatEngineFailure failure)) + { + return observation.Boolean("ok", false).Failure("readOriginalFailure", failure).Complete(); + } + + int[] values = [0x11111111, 0x22222222, 0x33333333, 0x44444444]; + MemoryPrimitiveBatchWriteOutcome outcome = active.Client.Memory.WritePrimitiveBatchDetailed( + new MemoryPrimitiveBatchWriteRequest( + [ + new MemoryAddressValue(first, values[0]), + new MemoryAddressValue(second, values[1]), + new MemoryAddressValue(new Address(invalid), values[2]), + new MemoryAddressValue(fourth, values[3]) + ])); + + bool readFirst = active.Client.Memory.TryReadPrimitive(first, out int firstValue, out _); + bool readSecond = active.Client.Memory.TryReadPrimitive(second, out int secondValue, out _); + bool readFourth = active.Client.Memory.TryReadPrimitive(fourth, out int fourthValue, out _); + int originalFourth = BitConverter.ToInt32(original.AsSpan(12, 4)); + bool restored = active.Client.Memory.TryWriteBytes(new MemoryBytesWriteRequest(first, original.AsSpan()), + out _); + + observation.Boolean("ok", true) + .BeginObject("outcome") + .Number("requested", outcome.RequestedCount) + .Number("completed", outcome.CompletedCount) + .Number("failedIndex", outcome.FailedIndex ?? -1) + .String("effectState", outcome.EffectState.ToString()); + if (outcome.Failure is { } cause) + { + observation.Failure("cause", cause); + } + + bool partialExposed = outcome.RequestedCount == 4 && outcome.CompletedCount == 2 && outcome.FailedIndex == 2 && + outcome.EffectState == MemoryBatchWriteEffectState.Partial; + bool readBackConfirms = readFirst && readSecond && readFourth && firstValue == values[0] && + secondValue == values[1] && fourthValue == originalFourth; + return observation.EndObject() + .BeginObject("checks") + .Boolean("partialEffectExposed", partialExposed) + .Boolean("readBackConfirms", readBackConfirms) + .Boolean("originalRestored", restored) + .EndObject() + .Complete(); + }); + } + + /// Creates one memory record on the scratch symbol through the Client table API and remembers its id (Q34). + internal static string TableCreate(string symbolName) + { + return RunMutating("table_create", (observation, active, processId) => + { + if (!TryResolveDeclaredScratch(observation, active, symbolName, processId, out _)) + { + return observation.Complete(); + } + + if (!active.Client.Tables.TryCreate(new MemoryRecordDefinition(RecordDescription, symbolName, "0", + VariableType.Dword), out MemoryRecordSnapshot record, out CheatEngineFailure failure)) + { + return observation.Boolean("ok", false).Failure("failure", failure).Complete(); + } + + lock (RecordGate) + { + _lastRecordId = record.Id.Value; + } + + return observation.Boolean("ok", true) + .Number("recordId", record.Id.Value) + .String("description", record.Content.Description) + .Complete(); + }); + } + + /// + /// Reads the record created by again after the driver destroyed it or reloaded the table + /// (Q34): the old id must be refused, never answered by another record. + /// + internal static string TableProbe() + { + return Guarded("table_probe", static observation => + { + if (!QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active)) + { + return Inactive(observation); + } + + int? recordId; + lock (RecordGate) + { + recordId = _lastRecordId; + } + + if (recordId is not { } id) + { + return observation.Boolean("ok", false).String("refusal", "NoRecordCreated").Complete(); + } + + bool found = active.Client.Tables.TryGetRecord(new MemoryRecordId(id), out MemoryRecordSnapshot record, + out CheatEngineFailure failure); + bool sameRecord = found && + string.Equals(record.Content.Description, RecordDescription, StringComparison.Ordinal); + observation.Boolean("ok", true).Number("recordId", id).Boolean("found", found).Boolean("sameRecord", sameRecord); + if (!found) + { + observation.Failure("failure", failure); + } + + return observation.BeginObject("checks") + .Boolean("oldReferenceRefused", !found) + .Boolean("reusedAsAnotherRecord", found && !sameRecord) + .EndObject() + .Complete(); + }); + } + + /// Registers a harness-prefixed symbol on the scratch address through the Client symbol lease (Q16.b). + internal static string SymbolRegister(string name, string symbolName) + { + return RunMutating("symbol_register", (observation, active, processId) => + { + if (!name.StartsWith(NamePrefix, StringComparison.Ordinal)) + { + return observation.Boolean("ok", false).String("refusal", "NameNotHarness").Complete(); + } + + if (!TryResolveDeclaredScratch(observation, active, symbolName, processId, out Address scratch)) + { + return observation.Complete(); + } + + if (!active.Client.Inspection.TryRegisterSymbol(new SymbolRegistration(name, scratch), + out ISymbolRegistrationLease? lease, out CheatEngineFailure failure)) + { + return observation.Boolean("ok", false).Failure("failure", failure).Complete(); + } + + QualificationSession.KeepSymbolLease(lease); + return observation.Boolean("ok", true).String("name", lease.Name).Address("address", lease.Address.Value) + .Complete(); + }); + } + + /// Disposes the harness's lease of a symbol, as a plugin disable would (Q16.b). + internal static string SymbolRelease(string name) + { + return RunMutating("symbol_release", (observation, _, _) => + { + if (!QualificationSession.TryGetSymbolLease(name, out ISymbolRegistrationLease? lease)) + { + return observation.Boolean("ok", false).String("refusal", "NoLease").Complete(); + } + + bool releasedBefore = lease.IsReleased; + lease.Dispose(); + return observation.Boolean("ok", true).Boolean("releasedBefore", releasedBefore) + .Boolean("released", lease.IsReleased).Complete(); + }); + } + + /// The harness's lease of a symbol and what the name resolves to now, through the Client (Q16.b). + internal static string SymbolState(string name) + { + return Guarded("symbol_state", observation => + { + if (!QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active)) + { + return Inactive(observation); + } + + bool hasLease = QualificationSession.TryGetSymbolLease(name, out ISymbolRegistrationLease? lease); + observation.Boolean("ok", true).Boolean("hasLease", hasLease); + if (lease is not null) + { + observation.BeginObject("lease").Address("address", lease.Address.Value) + .Boolean("released", lease.IsReleased).EndObject(); + } + + bool resolves = active.Client.Inspection.TryResolveAddress(new SymbolExpression(name), + AddressResolutionMode.Default, out Address current, out CheatEngineFailure failure); + observation.Boolean("resolves", resolves); + if (resolves) + { + observation.Address("resolvedAddress", current.Value) + .Boolean("resolvesToLeasedAddress", lease is not null && lease.Address == current); + } + else + { + observation.Failure("resolveFailure", failure); + } + + return observation.Complete(); + }); + } + + /// The captured log events: templates and event ids, never formatted messages (Q46). + internal static string Logs() + { + return Guarded("logs", static observation => + { + const int Reported = 64; + IReadOnlyList events = QualificationSession.Logs.Events(); + observation.Boolean("ok", true) + .Number("eventCount", events.Count) + .Number("dropped", QualificationSession.Logs.Dropped) + .Number("sensitiveHits", QualificationSession.Logs.SensitiveHits) + .BeginArray("events"); + for (int index = Math.Max(0, events.Count - Reported); index < events.Count; index++) + { + CapturedLogEvent captured = events[index]; + observation.BeginItem() + .String("category", captured.Category) + .Number("eventId", captured.EventId) + .String("eventName", captured.EventName) + .String("level", captured.Level.ToString()) + .String("template", captured.Template) + .EndObject(); + } + + return observation.EndArray().Complete(); + }); + } + + /// + /// Runs a function that changes the target or Cheat Engine state: only for an enabled activation and a gate that + /// authorized the run, and by default only while the Client observes exactly the authorized target + /// ( names the two narrower exceptions). + /// + internal static string RunMutating(string function, + Func body, + MutationScope scope = MutationScope.AuthorizedTarget) + { + return Guarded(function, observation => + { + if (!QualificationSession.TryGetActive(out QualificationSession.ActiveClient? active)) + { + return Inactive(observation); + } + + AuthorizationDecision gate = QualificationSession.Authorization; + bool observed = active.Client.Processes.TryRefresh(out ProcessSnapshot process, out _); + int processId = observed ? process.Id.Value : 0; + bool fileAsProcess = observed && process.Backend == TargetBackend.FileAsProcess; + WriteRefusal refusal = QualificationWriteGuard.EvaluateScope(gate, scope, processId, fileAsProcess); + if (refusal == WriteRefusal.NotAuthorized) + { + return observation.Boolean("ok", false).String("refusal", nameof(WriteRefusal.NotAuthorized)) + .String("denial", gate.Denial.ToString()).Complete(); + } + + return refusal == WriteRefusal.None + ? body(observation, active, processId) + : observation.Boolean("ok", false).String("refusal", refusal.ToString()).Complete(); + }); + } + + private static string Guarded(string function, Func body) + { + using QualificationObservation observation = new(function); + try + { + return body(observation); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + using QualificationObservation failed = new(function); + return failed.Boolean("ok", false).String("exception", exception.GetType().Name).Complete(); + } + } + + private static string Inactive(QualificationObservation observation) + { + return observation.Boolean("ok", false).String("refusal", "NoActiveClient").Complete(); + } + + private static int ClientProcessId(QualificationSession.ActiveClient active) + { + return active.Client.Processes.TryRefresh(out ProcessSnapshot process, out _) ? process.Id.Value : 0; + } + + private static string WriteCapabilities(QualificationObservation observation, + QualificationSession.ActiveClient active, bool policyRefusals) + { + int processIdBefore = ClientProcessId(active); + observation.Boolean("probeOnly", !policyRefusals).BeginArray("families"); + foreach (ClientCapabilityId capability in (ClientCapabilityId[]) + [ + ClientCapabilityId.ProcessSelection, ClientCapabilityId.TypedMemory, ClientCapabilityId.PatternScanning, + ClientCapabilityId.ValueScanning, ClientCapabilityId.Inspection, ClientCapabilityId.Tables, + ClientCapabilityId.ProtectedLua, ClientCapabilityId.UnsafeLuaExecution, ClientCapabilityId.Allocations, + ClientCapabilityId.Assembly, ClientCapabilityId.AutoAssemblerPatches + ]) + { + observation.BeginItem().String("capability", capability.Value); + if (!active.Client.Runtime.TryGetClientCapability(capability, + out ClientCapabilityAvailability availability, out CheatEngineFailure failure)) + { + observation.Failure("availabilityFailure", failure).EndObject(); + continue; + } + + ClientCapabilityEvidence evidence = availability.Evidence; + observation.String("state", availability.State.ToString()) + .String("effectiveReasonCode", evidence.EffectiveReasonCode.ToString()) + .BeginObject("gates") + .String("implementation", evidence.Implementation.State.ToString()) + .String("package", evidence.Package.State.ToString()) + .String("host", evidence.Host.State.ToString()) + .String("qualification", evidence.LiveQualification.State.ToString()) + .String("policy", evidence.Policy.State.ToString()) + .String("lifetime", evidence.Lifetime.State.ToString()) + .EndObject(); + if (policyRefusals && TryDescribeOptInService(active.Services, capability, out bool registered)) + { + // The opt-in registers the service and satisfies the policy gate together; without it no call exists, so + // the Client's own policy refusal (NotStarted, no host call) cannot be observed here: it is proven at C1. + observation.BeginObject("policyRefusal") + .Boolean("serviceRegistered", registered) + .EndObject(); + } + + observation.EndObject(); + } + + int processIdAfter = ClientProcessId(active); + return observation.EndArray() + .Number("processIdBefore", processIdBefore) + .Number("processIdAfter", processIdAfter) + .Boolean("processUnchanged", processIdBefore == processIdAfter) + .Boolean("ok", true) + .Complete(); + } + + // The services that only a builder opt-in registers (EnableAutoAssemblerPatches, EnableUnsafeLuaExecution): whether + // the activation's provider has one. Resolving a service makes no Cheat Engine call. + private static bool TryDescribeOptInService(IServiceProvider services, ClientCapabilityId capability, + out bool registered) + { + switch (capability.Value) + { + case "Client.AutoAssemblerPatches": +#pragma warning disable CECLIENT5004 // The harness observes the experimental Auto Assembler opt-in (Q35, Q44). + registered = services.GetService(typeof(IAutoAssemblerClient)) is not null; +#pragma warning restore CECLIENT5004 + return true; + case "Client.UnsafeLuaExecution": + registered = services.GetService(typeof(IUnsafeLuaClient)) is not null; + return true; + default: + registered = false; + return false; + } + } + + private static bool TryResolveScratch(QualificationObservation observation, QualificationSession.ActiveClient active, + string symbolName, out Address scratch, out MemoryRegionInfo region) + { + scratch = default; + region = default; + if (!symbolName.StartsWith(NamePrefix, StringComparison.Ordinal)) + { + observation.Boolean("ok", false).String("refusal", "SymbolNotHarness"); + return false; + } + + if (!active.Client.Inspection.TryResolveAddress(new SymbolExpression(symbolName), AddressResolutionMode.Default, + out scratch, out CheatEngineFailure failure)) + { + observation.Boolean("ok", false).Failure("resolveFailure", failure); + return false; + } + + if (!active.Client.Inspection.TryGetMemoryRegion(scratch, out region, out failure)) + { + observation.Boolean("ok", false).Failure("regionFailure", failure); + return false; + } + + return true; + } + + private static bool TryResolveDeclaredScratch(QualificationObservation observation, + QualificationSession.ActiveClient active, string symbolName, int processId, out Address scratch) + { + return TryResolveScratch(observation, active, symbolName, out scratch, out _) && + GuardWrite(observation, processId, scratch, ScratchLength); + } + + private static bool GuardWrite(QualificationObservation observation, int processId, Address address, int length) + { + WriteRefusal refusal = QualificationWriteGuard.Evaluate(QualificationSession.Authorization, processId, + QualificationSession.Declaration, address.Value, length); + if (refusal == WriteRefusal.None) + { + return true; + } + + observation.Boolean("ok", false).String("refusal", refusal.ToString()); + return false; + } + + private static string BytesRoundTrip(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, Address address, byte[] payload) + { + if (!GuardWrite(observation, processId, address, payload.Length) || + !TryReadOriginal(observation, active, address, payload.Length, out ImmutableArray original)) + { + return observation.Complete(); + } + + bool written = active.Client.Memory.TryWriteBytes(new MemoryBytesWriteRequest(address, payload), + out CheatEngineFailure failure); + bool read = active.Client.Memory.TryReadBytes(new MemoryBytesReadRequest(address, payload.Length), + out ImmutableArray readBack, out CheatEngineFailure readFailure); + Restore(observation, active, address, original); + observation.Boolean("ok", written && read).Number("writtenLength", payload.Length) + .Number("readLength", read ? readBack.Length : 0) + .Boolean("bytesEqual", read && readBack.AsSpan().SequenceEqual(payload)); + WriteFailures(observation, written ? null : failure, read ? null : readFailure); + return observation.Complete(); + } + + private static string StringRoundTrip(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, Address address, string value, bool wide) + { + byte[] expected = wide ? Encoding.Unicode.GetBytes(value) : Encoding.UTF8.GetBytes(value); + int window = expected.Length + (wide ? 2 : 1); + if (!GuardWrite(observation, processId, address, window) || + !TryReadOriginal(observation, active, address, window, out ImmutableArray original)) + { + return observation.Complete(); + } + + MemoryStringEncoding encoding = wide ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8; + bool written = active.Client.Memory.TryWriteString( + new MemoryStringWriteRequest(address, value, expected.Length, encoding), out CheatEngineFailure failure); + bool readBytes = active.Client.Memory.TryReadBytes(new MemoryBytesReadRequest(address, expected.Length), + out ImmutableArray readBack, out CheatEngineFailure readFailure); + bool readText = active.Client.Memory.TryReadString( + new MemoryStringReadRequest(address, value.Length, encoding), out string? text, out _); + Restore(observation, active, address, original); + observation.Boolean("ok", written && readBytes) + .String("encoding", wide ? "Utf16" : "Utf8") + .Number("expectedByteLength", expected.Length) + .Boolean("bytesEqual", readBytes && readBack.AsSpan().SequenceEqual(expected)) + .Boolean("textRead", readText) + .Boolean("textEqual", readText && string.Equals(text, value, StringComparison.Ordinal)) + .Number("textLength", readText ? text!.Length : -1); + WriteFailures(observation, written ? null : failure, readBytes ? null : readFailure); + return observation.Complete(); + } + + private static string Int32RoundTrip(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, Address address, int value) + { + if (!GuardWrite(observation, processId, address, sizeof(int)) || + !TryReadOriginal(observation, active, address, sizeof(int), out ImmutableArray original)) + { + return observation.Complete(); + } + + bool written = active.Client.Memory.TryWritePrimitive(address, value, out CheatEngineFailure failure); + bool readSigned = active.Client.Memory.TryReadPrimitive(address, out int signed, out _); + bool readUnsigned = active.Client.Memory.TryReadPrimitive(address, out uint unsigned, out _); + bool readBytes = active.Client.Memory.TryReadBytes(new MemoryBytesReadRequest(address, sizeof(int)), + out ImmutableArray bytes, out _); + Restore(observation, active, address, original); + observation.Boolean("ok", written && readSigned && readUnsigned && readBytes) + .Number("signed", signed) + .Number("unsigned", unsigned) + .String("bytes", readBytes ? Convert.ToHexString(bytes.AsSpan()) : null) + .Boolean("signedEqual", readSigned && signed == value) + .Boolean("unsignedEqual", readUnsigned && unsigned == unchecked((uint) value)); + WriteFailures(observation, written ? null : failure, null); + return observation.Complete(); + } + + private static string UInt32RoundTrip(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, Address address, uint value) + { + if (!GuardWrite(observation, processId, address, sizeof(uint)) || + !TryReadOriginal(observation, active, address, sizeof(uint), out ImmutableArray original)) + { + return observation.Complete(); + } + + bool written = active.Client.Memory.TryWritePrimitive(address, value, out CheatEngineFailure failure); + bool readUnsigned = active.Client.Memory.TryReadPrimitive(address, out uint unsigned, out _); + bool readSigned = active.Client.Memory.TryReadPrimitive(address, out int signed, out _); + Restore(observation, active, address, original); + observation.Boolean("ok", written && readUnsigned && readSigned) + .Number("unsigned", unsigned) + .Number("signed", signed) + .Boolean("unsignedEqual", readUnsigned && unsigned == value) + .Boolean("signedEqual", readSigned && signed == unchecked((int) value)); + WriteFailures(observation, written ? null : failure, null); + return observation.Complete(); + } + + private static string Int64RoundTrip(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, Address address) + { + // 2^53 + 1 is the first integer a double cannot represent: a round trip through a double would lose it. + long[] signedValues = [long.MinValue, long.MaxValue, -1, (1L << 53) + 1]; + ulong[] unsignedValues = [ulong.MaxValue, (1UL << 63) + 1]; + if (!GuardWrite(observation, processId, address, sizeof(long)) || + !TryReadOriginal(observation, active, address, sizeof(long), out ImmutableArray original)) + { + return observation.Complete(); + } + + bool allEqual = true; + observation.BeginArray("values"); + foreach (long value in signedValues) + { + bool written = active.Client.Memory.TryWritePrimitive(address, value, out _); + bool read = active.Client.Memory.TryReadPrimitive(address, out long readBack, out _); + bool equal = written && read && readBack == value; + allEqual &= equal; + observation.BeginItem().String("type", "long").String("value", value.ToString(CultureInfo.InvariantCulture)) + .Boolean("equal", equal).EndObject(); + } + + foreach (ulong value in unsignedValues) + { + bool written = active.Client.Memory.TryWritePrimitive(address, value, out _); + bool read = active.Client.Memory.TryReadPrimitive(address, out ulong readBack, out _); + bool equal = written && read && readBack == value; + allEqual &= equal; + observation.BeginItem().String("type", "ulong").String("value", value.ToString(CultureInfo.InvariantCulture)) + .Boolean("equal", equal).EndObject(); + } + + Restore(observation.EndArray(), active, address, original); + return observation.Boolean("ok", true).Boolean("allEqual", allEqual).Complete(); + } + + private static string AddressRoundTrip(QualificationObservation observation, QualificationSession.ActiveClient active, + int processId, Address address) + { + const ulong AboveFourGibibytes = 0x0000_7FF7_1234_5678; + if (!GuardWrite(observation, processId, address, sizeof(ulong)) || + !TryReadOriginal(observation, active, address, sizeof(ulong), out ImmutableArray original)) + { + return observation.Complete(); + } + + bool written = active.Client.Memory.TryWritePrimitive(address, new Address(AboveFourGibibytes), + out CheatEngineFailure failure); + bool readAddress = active.Client.Memory.TryReadPrimitive(address, out Address readBack, out _); + bool readRaw = active.Client.Memory.TryReadPrimitive(address, out ulong raw, out _); + Restore(observation, active, address, original); + observation.Boolean("ok", written && readAddress && readRaw) + .Boolean("valueEqual", readAddress && readBack.Value == AboveFourGibibytes) + .Boolean("rawEqual", readRaw && raw == AboveFourGibibytes) + .Boolean("scratchAboveFourGibibytes", address.Value > uint.MaxValue); + WriteFailures(observation, written ? null : failure, null); + + // The target's own image base (above 4 GiB for the x64 qualification target) is read, never written. + if (active.Client.Inspection.TryGetModules(new InspectionCollectionRequest(ModuleLimit), null, + out ImmutableArray modules, out _) && + modules.FirstOrDefault(static module => module.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) is + { Name.Length: > 0 } image) + { + bool readHeader = active.Client.Memory.TryReadBytes(new MemoryBytesReadRequest(image.BaseAddress, 2), + out ImmutableArray header, out _); + observation.BeginObject("imageBase") + .Address("address", image.BaseAddress.Value) + .Boolean("aboveFourGibibytes", image.BaseAddress.Value > uint.MaxValue) + .Boolean("headerIsMz", readHeader && header.Length == 2 && header[0] == 0x4D && header[1] == 0x5A) + .EndObject(); + } + + return observation.Complete(); + } + + private static bool TryReadOriginal(QualificationObservation observation, QualificationSession.ActiveClient active, + Address address, int length, out ImmutableArray original) + { + if (active.Client.Memory.TryReadBytes(new MemoryBytesReadRequest(address, length), out original, + out CheatEngineFailure failure)) + { + return true; + } + + observation.Boolean("ok", false).Failure("readOriginalFailure", failure); + return false; + } + + private static void Restore(QualificationObservation observation, QualificationSession.ActiveClient active, + Address address, ImmutableArray original) + { + observation.Boolean("originalRestored", + active.Client.Memory.TryWriteBytes(new MemoryBytesWriteRequest(address, original.AsSpan()), out _)); + } + + private static void WriteFailures(QualificationObservation observation, CheatEngineFailure? writeFailure, + CheatEngineFailure? readFailure) + { + if (writeFailure is { } write) + { + observation.Failure("writeFailure", write); + } + + if (readFailure is { } read) + { + observation.Failure("readFailure", read); + } + } + + private static void WriteMetrics(QualificationObservation observation, PatternScanMetrics? metrics) + { + if (metrics is not { } value) + { + observation.Boolean("metricsReported", false); + return; + } + + observation.Boolean("metricsReported", true) + .BeginObject("metrics") + .String("scope", value.Scope.ToString()) + .Number("hostResultCount", Saturate(value.HostResultCount)) + .Number("examinedCount", Saturate(value.ExaminedCount)) + .Number("filteredOutCount", Saturate(value.FilteredOutCount)) + .Number("materializedCount", value.MaterializedCount) + .Number("belowStartSkippedCount", Saturate(value.BelowStartSkippedCount)) + .Number("atOrAfterStopSkippedCount", Saturate(value.AtOrAfterStopSkippedCount)) + .Number("unreadHostRowCount", Saturate(value.UnreadHostRowCount)) + .Boolean("inBoundsCountIsExact", value.InBoundsCountIsExact) + .Number("hostScanMicroseconds", Microseconds(value.HostScanElapsed)) + .Number("materializationMicroseconds", Microseconds(value.MaterializationElapsed)) + .EndObject(); + } + + private static long Saturate(ulong count) + { + return (long) Math.Min(count, long.MaxValue); + } + + private static void WriteModuleExactness(QualificationObservation observation, + QualificationSession.ActiveClient active, AobPattern pattern, ModuleName module, int limit, + List filtered) + { + if (!active.Client.Inspection.TryGetModules(new InspectionCollectionRequest(ModuleLimit), null, + out ImmutableArray modules, out CheatEngineFailure failure)) + { + observation.Failure("moduleFailure", failure); + return; + } + + ModuleInfo? match = null; + foreach (ModuleInfo candidate in modules) + { + if (string.Equals(candidate.Name, module.Value, StringComparison.OrdinalIgnoreCase)) + { + match = candidate; + break; + } + } + + if (match is not { ImageSize: { } size } found) + { + observation.Boolean("moduleFound", false); + return; + } + + // The Client's one module rule on every route (PatternScanner.IsInsideRequest): a match is kept only when all of + // its pattern bytes lie inside [BaseAddress, BaseAddress + ImageSize), so a match straddling the module end is + // never reported. + ulong start = found.BaseAddress.Value; + ulong length = (ulong) Math.Max(pattern.ByteLength, 1); + bool WholeMatchInside(ulong address) => + length <= size.Value && address >= start && address - start <= size.Value - length; + + bool allInside = filtered.TrueForAll(WholeMatchInside); + PatternScanOutcome global = active.Client.Patterns.ScanDetailed(new AobScanRequest(pattern, limit)); + List globalMatches = global.Result is { } result + ? [.. result.Matches.Select(static address => address.Value)] + : []; + HashSet globalInside = [.. globalMatches.Where(WholeMatchInside)]; + bool globalComplete = global.IsSuccess && global.Result is { IsTruncated: false }; + observation.Boolean("moduleFound", true) + .BeginObject("moduleCheck") + .Address("base", start) + .Number("size", (long) size.Value) + .Boolean("containsBase", filtered.Contains(start)) + .Boolean("allInside", allInside) + .String("globalHostOutcome", global.HostOutcome.ToString()) + .Boolean("globalComplete", globalComplete) + .Number("globalCount", globalMatches.Count) + .Number("globalInsideCount", globalInside.Count) + .Boolean("filterExact", globalComplete && allInside && globalInside.SetEquals(filtered)) + .EndObject(); + } + + private static void Identity(QualificationObservation observation, string role, System.Reflection.Assembly assembly) + { + observation.BeginItem() + .String("role", role) + .String("name", assembly.GetName().Name) + .String("informationalVersion", + assembly.GetCustomAttribute()?.InformationalVersion) + .String("mvid", assembly.ManifestModule.ModuleVersionId.ToString()) + .EndObject(); + } + + private static string? BridgeSha256() + { + string? directory = QualificationSession.PluginDirectory; + string? bridge = directory is null ? null : Path.Combine(directory, BridgeFileName); + if (bridge is null || !File.Exists(bridge)) + { + return null; + } + + using FileStream stream = File.OpenRead(bridge); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } + + private static long Microseconds(TimeSpan elapsed) + { + return (long) Math.Round(elapsed.TotalMicroseconds); + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationSession.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationSession.cs new file mode 100644 index 0000000..abf24dd --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationSession.cs @@ -0,0 +1,252 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Scanning; + +using LivePlugin.Qualification.Harness; + +// The harness keeps the leases of the experimental value scans (CECLIENT5001), allocations (CECLIENT5002) and Auto +// Assembler patches (CECLIENT5004) it created; the source is compiled standalone against the packed packages. +#pragma warning disable CECLIENT5001, CECLIENT5002, CECLIENT5004 + +namespace LivePlugin.Qualification; + +/// +/// The harness state the Lua functions read. It holds the Client of the current activation (set by the scenario +/// module's OnEnabled, cleared by its OnDisabling, so a Lua call never reaches an expired activation), +/// the gate decision and session inputs of the current enable, the declared writable region, the leases the harness +/// created (symbols, allocations, the value-scan session, the Auto Assembler patch) and the log sink. Everything here +/// outlives a disable on purpose: Cheat Engine never unloads a managed plugin, and the scenarios read after a +/// re-enable, or after a target change, what the leases of an earlier step became. +/// +internal static class QualificationSession +{ + private static readonly Lock Gate = new(); + private static readonly Dictionary SymbolLeases = new(StringComparer.Ordinal); + private static readonly Dictionary AllocationLeases = new(StringComparer.Ordinal); + private static ActiveClient? _active; + private static AuthorizationDecision _authorization = AuthorizationDecision.Denied(AuthorizationDenial.ManifestMissing); + private static TargetDeclaration? _declaration; + private static QualificationInputs _inputs = QualificationInputs.None; + private static IAutoAssemblerPatchLease? _patch; + private static string? _pluginDirectory; + private static uint _pluginId; + private static ValueScanState? _valueScan; + + /// Gets the log sink registered in every activation's logging pipeline (Q46). + internal static CapturingLoggerProvider Logs + { + get; + } = new(); + + /// Gets the gate decision evaluated by the last enable. + internal static AuthorizationDecision Authorization + { + get + { + lock (Gate) + { + return _authorization; + } + } + } + + /// Gets the writable region declaration, or . + internal static TargetDeclaration? Declaration + { + get + { + lock (Gate) + { + return _declaration; + } + } + } + + /// + /// Gets the folder the plugin was loaded from, recorded by the last enable, or when the + /// plugin assembly has no file location. + /// + internal static string? PluginDirectory + { + get + { + lock (Gate) + { + return _pluginDirectory; + } + } + } + + /// Gets the SDK plugin id recorded by the last enable. + internal static uint PluginId + { + get + { + lock (Gate) + { + return _pluginId; + } + } + } + + /// Gets the session inputs read by the last enable. + internal static QualificationInputs Inputs + { + get + { + lock (Gate) + { + return _inputs; + } + } + } + + /// Gets or sets the value-scan session the harness created, with the scratch slot it scans for. + internal static ValueScanState? ValueScan + { + get + { + lock (Gate) + { + return _valueScan; + } + } + set + { + lock (Gate) + { + _valueScan = value; + } + } + } + + /// Gets or sets the Auto Assembler patch lease the harness applied. + internal static IAutoAssemblerPatchLease? Patch + { + get + { + lock (Gate) + { + return _patch; + } + } + set + { + lock (Gate) + { + _patch = value; + } + } + } + + /// Records the facts of a new enable, before any activation exists. + internal static void BeginEnable(uint pluginId, AuthorizationDecision authorization, QualificationInputs inputs, + string? pluginDirectory) + { + lock (Gate) + { + _pluginId = pluginId; + _authorization = authorization; + _inputs = inputs; + _pluginDirectory = pluginDirectory; + } + } + + /// Publishes the Client and the service provider of the activation that just enabled. + internal static void Attach(ICheatEngineClient client, IServiceProvider services) + { + lock (Gate) + { + _active = new ActiveClient(client, services); + } + } + + /// Withdraws the Client of the activation that is being disabled. + internal static void Detach() + { + lock (Gate) + { + _active = null; + } + } + + /// The Client of the current activation, if one is enabled. + internal static bool TryGetActive([NotNullWhen(true)] out ActiveClient? active) + { + lock (Gate) + { + active = _active; + return active is not null; + } + } + + /// Stores the writable regions declared for the authorized target. + internal static void Declare(TargetDeclaration declaration) + { + lock (Gate) + { + _declaration = declaration; + } + } + + /// Keeps a symbol lease created by the harness, replacing (and disposing) an older one of the same name. + internal static void KeepSymbolLease(ISymbolRegistrationLease lease) + { + ISymbolRegistrationLease? previous; + lock (Gate) + { + SymbolLeases.TryGetValue(lease.Name, out previous); + SymbolLeases[lease.Name] = lease; + } + + if (previous is not null && !ReferenceEquals(previous, lease)) + { + previous.Dispose(); + } + } + + /// The symbol lease the harness created for a name, released or not. + internal static bool TryGetSymbolLease(string name, [NotNullWhen(true)] out ISymbolRegistrationLease? lease) + { + lock (Gate) + { + return SymbolLeases.TryGetValue(name, out lease); + } + } + + /// + /// Keeps an allocation lease under a scenario name. An older lease of the same name is only forgotten, never + /// released here: a lease of an earlier target must keep the outcome the Client gave it. + /// + internal static void KeepAllocation(string name, ITargetMemoryLease lease) + { + lock (Gate) + { + AllocationLeases[name] = lease; + } + } + + /// The allocation lease the harness created under a name, released or not. + internal static bool TryGetAllocation(string name, [NotNullWhen(true)] out ITargetMemoryLease? lease) + { + lock (Gate) + { + return AllocationLeases.TryGetValue(name, out lease); + } + } + + /// + /// The Client of one activation and its service provider, from which the harness resolves the services that + /// exist only through a DI opt-in (IAutoAssemblerClient, IUnsafeLuaClient). + /// + internal sealed record ActiveClient( + ICheatEngineClient Client, + IServiceProvider Services); + + /// The value-scan session of the harness, the scratch slot it marks and the bytes the marker replaced. + internal sealed record ValueScanState(IValueScanSession Session, ulong Slot, byte[] OriginalBytes); +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationWorkerProbe.cs b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationWorkerProbe.cs new file mode 100644 index 0000000..26ae3ef --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/QualificationWorkerProbe.cs @@ -0,0 +1,26 @@ +using CheatEngine.Client.Lua; +using CheatEngine.SDK.Annotations.Lua; + +namespace LivePlugin.Qualification; + +/// +/// The Q19 probe module. It is never added to the activation: worker_admission("start") calls its generated +/// directly from a worker thread, where CheatEngine.SDK must refuse the Lua +/// admission, so its global must never exist in a qualification run. +/// +[CheatEngineLuaModule(typeof(QualificationWorkerProbeFunctions), "client_qualification_worker_probe")] +internal sealed partial class QualificationWorkerProbeModule : ILuaModule; + +/// The one export of the Q19 probe module. +internal static partial class QualificationWorkerProbeFunctions +{ + /// The global the driver checks is absent after the worker's refused registration. + internal const string ProbeGlobal = "cheatengine_client_qualification_worker_probe"; + + /// Never callable in a qualification run: its registration from a worker is refused. + [LuaFunction(ProbeGlobal)] + public static string Probe() + { + return "worker-probe-registered"; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/README.md b/tests/CheatEngine.Client.LivePlugin.Qualification/README.md new file mode 100644 index 0000000..0083bb7 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/README.md @@ -0,0 +1,163 @@ +# CheatEngine.Client.LivePlugin.Qualification + +## Context + +The Client qualification harness: a real `CheatEngineClientPlugin`, composed like an application, whose Lua functions +run exact-host (C3/C4) Client scenarios one step at a time and return one JSON observation per call (schema +`cheatengine-client-qualification-observation/v0`). The live qualification runner of `CheatEngine.Client.Tests` +(section [Live qualification](../CheatEngine.Client.Tests/README.md#live-qualification)) loads it into a sandboxed copy +of Cheat Engine 7.7 and turns those observations into receipts. CI compiles it through the solution so that it never +rots; it is **never packed, never a test module and never loaded by CI**. + +## Why this project exists + +A C1 success never counts as a C3 success (audit `analyses/20`). The Client needs a plugin that exercises its own public +API on the exact host, with the exact CI packages, to produce Client receipts: plugin lifecycle and rollback (Q05, Q06, +Q43), worker admission (Q19), memory codecs (Q20, Q21, Q33), value scans (Q25, Q26), AOB scans (Q27–Q29), allocations +(Q30.a, Q30.b), runtime, target and instruction facts (Q31, Q32), tables and symbols (Q16.b, Q34), Auto Assembler +patches (Q35), capabilities (Q44, Q45), logs (Q46) and the Lua marshalling rules of CRIT-07. The SDK harnesses cannot +stand in for it: they do not go through the Client. + +## How it helps improve CheatEngine.Client + +- **Client API only** (ADR-01). Every Cheat Engine interaction goes through `ICheatEngineClient` (its + `IPatternScanner.ScanDetailed` included) and the services the activation registers. Cheat Engine-level setup + (opening the target, allocating the scratch region, changing the pointer size, redefining a symbol, destroying a + record) belongs to the session's driver, which the runner generates from its session plan. +- **Honest observations.** An observation is bounded and redacted by construction: failures are written as their kind, + operation and host effect only, address lists as their count and first and last eight entries, and any text that + looks like a local path is replaced. The harness checks its own criteria where the Client API allows it (for example + `checks.filterExact` of a module scan compares it with the unfiltered scan) and records the raw facts next to them. +- **Lifecycle record.** Cheat Engine never unloads a managed plugin, so the harness keeps a bounded ledger of every + enable, module stage, rollback and cleanup (stage names and exception types only) that `status()` reports after a + re-enable. + +The harness files that do not touch Lua (`Harness/`) are compiled into +[`CheatEngine.Client.LivePlugin.Qualification.Tests`](../CheatEngine.Client.LivePlugin.Qualification.Tests/README.md) +and tested there. + +## Safety boundary + +- **Qualification gate** (`Harness/QualificationAuthorization.cs`, fail-closed, re-implemented from the SDK LiveProbe + gate, so one runner-written manifest authorizes any harness). A mutating function runs only when the process variable + `CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT` holds the exact phrase, `CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE` names a + `ce77-live-probe-v1` manifest that is valid for at most 30 minutes and marks its target disposable, the host process is + the pinned `cheatengine-x86_64.exe` 7.7.0.10621 (SHA-256 and file version), and the declared target is alive with the + declared image hash. The runner's `AuthorizationManifestWriter` writes that manifest for every session. +- **Target check.** A mutating function also requires the process the Client observes to be exactly the authorized + target; the gate never authorizes Cheat Engine itself. Two narrower scopes exist (`MutationScope`), still behind the + gate: `OwnedResource` only releases a lease the harness created on the authorized target, after a target change too, + because what the Client does then (refuse to free anything in another process) is what S3 observes; and + `FileAsProcessTarget` runs only while Cheat Engine targets a file opened as a process, where no process exists, to + check that the Client creates no allocation or scan session there (anything created by mistake is released at once + and reported). +- **Write guard** (`Harness/QualificationWriteGuard.cs`). Writes go only into the scratch region the driver allocated + (`alloc(cheatengine_client_qualification_scratch,4096)` with `registersymbol`) and `target_declare` verified through + the Client inspection API (a committed, private, writable region that holds 4096 bytes). Every written range must lie + inside it; the original bytes are read first and restored (the value-scan marker slot when its session is released, + in the authorized target only). The partial-batch scenario writes one element at `0x10` on purpose, an address of + the never-mapped first 64 KiB. Allocations and Auto Assembler patches write nothing through the harness: Cheat Engine + allocates their memory in the authorized target, and the harness releases them. +- **Fault switch** (`Harness/QualificationFaultSwitch.cs`). The runner writes `liveprobe.fault.json` + (`ce77-live-probe-fault-v1`) next to the plugin for a session with a fault stage, and removes it when the session plan + says so. It is read once per enable and honored only when the gate allowed the run: `Configure`, + `ModuleOnEnabled`, `ModuleOnDisabling`, `ResourceCleanup` or `ModuleOnDisablingAndResourceCleanup`. +- **Log sink** (`Harness/CapturingLoggerProvider.cs`). It records category, event id, level and message template of + every event (Debug included), never the formatted message, and counts the events whose formatted text carries a + declared scenario value, a target address or the script marker (Q46). The plugin also composes the Hosting host log + provider (`AddCheatEngineHostLog`, message templates only), so the Cheat Engine debug output that Q46 reads carries + the templates of the events the host log admits (`Information` and above by default). +- **Session inputs** (`Harness/QualificationInputs.cs`). The runner's `CECLIENT_QUALIFICATION_*` variables are honored + only when the gate allowed the run: `CECLIENT_QUALIFICATION_ENABLE_AA=1` composes `EnableAutoAssemblerPatches()` (Q35; + without it `aa_patch` is refused and Q44 observes the policy refusal), `CECLIENT_QUALIFICATION_TABLE_ROOT` is the one + allowed table root (Q34), and `CECLIENT_QUALIFICATION_LIFECYCLE_FILE` names the lifecycle receipt sink + (`Harness/QualificationLifecycleSink.cs`, Q43): every lifecycle record entry and the template of every captured log + event is appended there, so the runner reads what a disable did after the last Lua call, the operator's or the one at + `closeCE`. Each enable starts with its `configure` entry, which names the fault the switch selected. + +## Lua functions + +Every name starts with `cheatengine_client_qualification_`. A function marked **yes** changes the target or Cheat Engine +state and runs only behind the gate and the target check (`QualificationScenarios.RunMutating`). + +| Function | Mutating | Scenarios | Returns | +|----------------------------------------------------------|---------------------------|--------------------|------------------------------------------------------------------------------------------------------| +| `status()` | no | Q05, Q06, Q40, Q43 | Gate and fault decisions, plugin id, epoch, activation count, assembly identities, bridge SHA-256, lifecycle ledger | +| `runtime()` | no | Q31, Q32, Q45 | Process id first; backend, architecture, bitness and configured pointer size; host version, OS and Cheat Engine bitness | +| `capabilities(probeOnly)` | no | Q44, Q45 | State and evidence gates of every Client capability; with 0, the policy refusal of each opt-in capability | +| `target_declare(symbolName)` | yes | Q20, Q21, Q33, Q34, Q16.b | The verified scratch region, or why it was refused | +| `aob(pattern, moduleName, maxResults, cancelAfterMs)` | no | Q27, Q28, Q29, Q46 | Route, scope, host outcome, route reason, failure kind, bounded matches, metrics, zero-result and exactness checks | +| `memory_roundtrip(kind, symbolName)` | yes | Q20, Q21, Q46 | Bytes, strings, 32/64-bit boundaries or an address above 4 GiB written, read back exactly, restored | +| `memory_batch_partial(symbolName, invalidAddress)` | yes | Q33, Q46 | Requested, completed and failed index, effect state, read-back confirmation | +| `table_create(symbolName)` | yes | Q34 | The created record id | +| `table_probe()` | no | Q34 | Whether the remembered record id is refused or answered by another record | +| `symbol_register(name, symbolName)` | yes | Q16.b | The Client symbol lease | +| `symbol_release(name)` | yes | Q16.b | Whether the lease released its registration | +| `symbol_state(name)` | no | Q16.b | The lease and what the name resolves to now | +| `logs()` | no | Q46 | Captured templates and event ids, and the sensitive-data hit count; never a formatted message | +| `value_scan(action, symbolName, value)` | yes, except `state` | Q25, Q26 | Session state, result count and whether the scratch marker is found; the decimal tolerance cases | +| `allocation(action, name, size)` | yes, except `state` | Q30.a, Q30.b | The lease, its region through the inspection API, and every release outcome | +| `instructions(symbolName)` | yes | Q32 | Assembled bytes of a fixed list and both jump encodings, and a disassembly round trip in the scratch | +| `aa_patch(action, variant)` | yes, except `state` | Q35, Q44 | Check result, the patch lease or the failure, whether its symbol resolves, and its release outcome | +| `worker_admission(action)` | `start` | Q19 | `"pending":true`, then the marshalled call and the refused direct registration from the worker | +| `table_save(fileName, outsideRoot)` | yes | Q34 | Whether the table was saved below the table root, or refused outside it | +| `table_load(fileName)` | yes | Q34 | Whether the table was loaded (which ends every earlier record id) | +| `integer_echo(value)` | no | CRIT-07 | The integer CheatEngine.SDK marshalled | +| `address_echo(address)` | no | CRIT-07 | The address CheatEngine.SDK marshalled | + +`memory_roundtrip` kinds: `bytes-with-nul`, `utf8-multibyte`, `utf16-with-nul`, `int32-minus-one`, `uint32-max`, +`int64-limits`, `address-above-4gib`. + +`runtime()` reports the pointer size Cheat Engine is configured with as the Client exposes it +(`configuredPointerSize.exposedByClient: true`, with `bytes` and `differsFromBitness`): the Q31 driver changes it with +`setPointerSize` and restores it, and the harness never derives it from the target bitness. A fact the Client reports as +unknown is written as `null`. + +`aob()` names the route that ran (`route.scope`, `route.hostOutcome`, `route.reason`, +`route.targetIdentityVerified`) and every metric of `PatternScanMetrics`. A global scan that finds nothing is +indeterminate (`checks.globalZeroIsIndeterminate`: `IndeterminateHostResult` with the host outcome `NoResult`); a +bounded scan that finds nothing is a factual empty result (`checks.boundedZeroIsNoMatches`). Truncation is proven by the +Client's own counts (`checks.truncationExplicit`), and the harness holds no copy of the Client's copy cap. + +`value_scan` actions: `first` and `next` write the int32 marker `value` into its scratch slot and scan the scratch region +for it, `reset`, `state`, `release` (also after a target change), `decimals` (Q25: the probe 3.14159 written as a float +and a double must be found by the texts of `FromSingle`/`FromDouble` with 5, 2 and 0 decimals and must not be found by +3.2 or 3.15, under either rule that fits Cheat Engine's `rtRounded` documentation; the 3-decimal text 3.142 is recorded +without an expectation, because ordinary rounding finds the probe with it and the documented range, which reaches only +half a unit above the text, does not) and `create-unidentified` (a file opened as a process). `allocation` actions: `allocate`, `state`, `release` and `allocate-unidentified`. `aa_patch` actions: `check`, +`apply` (variant `benign`: an allocation and a registered symbol that its `[DISABLE]` section unregisters and frees; +variant `failing`: a write to an undefined label), `state` and `release`. The driver selects every target through +Cheat Engine itself (`openProcess`), never through the Client, so a patch always applies to a process that Cheat Engine's +own selection chose. + +`worker_admission` never blocks Cheat Engine's main thread: `start` returns at once, and the worker's marshalled call +completes while the driver polls `result`. The probe module of the direct registration +(`QualificationWorkerProbe.cs`) is never added to the activation; its global must stay absent. + +`integer_echo` and `address_echo` exist for CRIT-07: CheatEngine.SDK's marshallers accept an integer exactly and refuse a +float from 2^53 on with a Lua error, which the driver records. + +`capabilities(0)` is the Q44 observation: without `EnableAutoAssemblerPatches` the activation registers no +`IAutoAssemblerClient` (and without `EnableUnsafeLuaExecution` no `IUnsafeLuaClient`), so no call can reach Cheat Engine, +and the capability reports a `Missing` policy gate. The harness only reads the capability evidence and resolves the +service; it makes no other call. This departs from the plan's Q44, which asked to observe a `NotStarted` refusal with no +host call: without the opt-in there is no client to call, so the Client's own refusal (a policy refusal with +`NotStarted`, before any port call, when a composition without the opt-in reaches `AutoAssemblerClient`) is proven at +C1 only, by `AutoAssemblerClientTests` in `CheatEngine.Client.Core.Tests`. The live receipts state what they observed: +the missing service and the `Missing` policy gate. + +## Run + +CI builds the project with the solution; nothing runs it. To see the complete deployment closure the Hosting targets +produce from this source graph: + +```powershell +dotnet build .\tests\CheatEngine.Client.LivePlugin.Qualification\CheatEngine.Client.LivePlugin.Qualification.csproj -c Release -p:CheatEnginePluginOutputPath= +``` + +A host run never uses that build. The sandboxed runner of `CheatEngine.Client.Tests` (section +[Live qualification](../CheatEngine.Client.Tests/README.md#live-qualification)) compiles the same sources again outside +the repository against the exact packed Client packages and the pinned CheatEngine.SDK 2.0.0 from nuget.org, and loads +them into a private copy of Cheat Engine, only on an explicit opt-in and never in CI. It compiles `QualificationAuthorization` +and `QualificationFaultSwitch` in, so the manifest and fault switch it writes are proven against this harness's own +parsers. diff --git a/tests/CheatEngine.Client.LivePlugin.Qualification/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Qualification/packages.lock.json new file mode 100644 index 0000000..61d96d1 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Qualification/packages.lock.json @@ -0,0 +1,159 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "CheatEngine.SDK": { + "type": "Direct", + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" + }, + "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==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Repository.Tests/Capabilities/CapabilityDocumentationTests.cs b/tests/CheatEngine.Client.Repository.Tests/Capabilities/CapabilityDocumentationTests.cs new file mode 100644 index 0000000..1f1abdc --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Capabilities/CapabilityDocumentationTests.cs @@ -0,0 +1,316 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Capabilities; + +/// +/// The documentation states what the Client build reports (audit CLI-DOC-1, A00-04, A10-05, A21-13, A21-21, A21-30): +/// every capability table lists each ClientCapabilityId once as the operational adapter that +/// ClientCapabilityCatalog declares and RuntimeClient composes, with its experimental id and the live +/// scenarios its qualification gate requires, and a table with a "1.0 status" column calls exactly the experimental +/// APIs Experimental; the install guides state the supported host profile with its identities. +/// +/// +/// Source scans only: this project has no project reference. A capability table is the Markdown table between +/// <!-- capability-table:start --> and <!-- capability-table:end --> in any Markdown file, so +/// a table copied into another document is checked as soon as it carries the markers. +/// +public sealed partial class CapabilityDocumentationTests +{ + private const string StartMarker = ""; + private const string EndMarker = ""; + private const string AbstractionsReadme = "libs/CheatEngine.Client.Abstractions/README.md"; + private const string CapabilityIdSource = "libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs"; + private const string CapabilityIdDeclarationPrefix = "public static ClientCapabilityId "; + private const string CatalogSource = "libs/CheatEngine.Client.Core/Domains/ClientCapabilityCatalog.cs"; + private const string CoreLockFile = "libs/CheatEngine.Client.Core/packages.lock.json"; + private const string QualificationColumn = "Qualification"; + + /// The optional column of a plugin-author table that says what 1.0 offers for each capability. + private const string StatusColumn = "1.0 status"; + + private const string AvailableStatus = "Available"; + private const string ExperimentalStatus = "Experimental"; + private const string ProfileId = "ce-7.7.0.10621-x64-managed-hostfxr"; + private const string HostExecutableSha256 = "9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d"; + private const string RuntimeConfigurationSha256 = "68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b"; + private const int RegexTimeoutMilliseconds = 1000; + + private static readonly string[] OperationalImplementations = ["Operational adapter", "Operational, policy opt-in"]; + + private static readonly string[] InstallGuides = + [ + "src/CheatEngine.Client/README.md", + "templates/CheatEngine.Client.Templates/README.md", + "templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md" + ]; + + [Fact] + public void CapabilityTablesListEveryClientCapabilityExactlyOnce() + { + string[] expected = [.. ReadCapabilityIds().Values.Order(StringComparer.Ordinal)]; + IReadOnlyList tables = ReadCapabilityTables(); + // Every static declaration in the source must be parsed, so a declaration the pattern misses cannot drop a row. + int declared = Read(CapabilityIdSource).Split(CapabilityIdDeclarationPrefix, StringSplitOptions.None).Length - 1; + + Assert.True(declared > 0, $"{CapabilityIdSource} declares no Client capability."); + Assert.Equal(declared, expected.Length); + Assert.Contains(tables, static table => table.Path == AbstractionsReadme); + foreach (CapabilityTable table in tables) + { + string[] listed = [.. table.Rows.Select(static row => row.Id)]; + Assert.True(listed.Length == listed.Distinct(StringComparer.Ordinal).Count(), + $"{table.Path} lists a capability id more than once: {string.Join(", ", listed)}."); + Assert.Equal(expected, listed.Order(StringComparer.Ordinal)); + } + } + + [Fact] + public void CapabilityTablesMatchTheCatalogImplementationGates() + { + Dictionary experimental = ReadExperimentalDiagnosticIds(); + List offenders = []; + foreach (CapabilityTable table in ReadCapabilityTables()) + { + foreach (CapabilityRow row in table.Rows) + { + bool matches = experimental.TryGetValue(row.Id, out string? diagnosticId) && + (diagnosticId is not null + ? OperationalImplementations.Any(operational => + row.Implementation == ExperimentalImplementation(operational, diagnosticId)) + : OperationalImplementations.Contains(row.Implementation, StringComparer.Ordinal)); + if (!matches) + { + offenders.Add($"{table.Path}:{row.Line} → {row.Id} says '{row.Implementation}'"); + } + } + } + + Assert.True(offenders.Count == 0, + "The Implementation column must name the catalog's operational adapter (" + + $"{string.Join(" or ", OperationalImplementations)}, or that label followed by " + + $"'{ExperimentalImplementation(string.Empty, "id")}' for an experimental API):" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void CapabilityTableStatusColumnsMarkExactlyTheExperimentalApis() + { + Dictionary experimental = ReadExperimentalDiagnosticIds(); + List offenders = []; + int checkedRows = 0; + foreach (CapabilityTable table in ReadCapabilityTables()) + { + foreach (CapabilityRow row in table.Rows) + { + if (row.Status is not { } status) + { + continue; + } + + checkedRows++; + bool isExperimental = experimental.GetValueOrDefault(row.Id) is not null; + string expected = isExperimental ? ExperimentalStatus : AvailableStatus; + if (!status.StartsWith(expected, StringComparison.Ordinal) || + (!isExperimental && status.Contains(ExperimentalStatus, StringComparison.OrdinalIgnoreCase))) + { + offenders.Add($"{table.Path}:{row.Line} → {row.Id} says '{status}', expected '{expected}...'"); + } + } + } + + Assert.True(checkedRows > 0, + $"No capability table has a '{StatusColumn}' column; the test would pass vacuously."); + Assert.True(offenders.Count == 0, + $"The '{StatusColumn}' column must start with '{ExperimentalStatus}' exactly for the capabilities whose " + + $"catalog row carries an experimental diagnostic id, and with '{AvailableStatus}' otherwise:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void CapabilityTablesNameTheScenariosTheCatalogRequires() + { + Dictionary ids = ReadCapabilityIds(); + Dictionary required = new(StringComparer.Ordinal); + foreach (Match match in CatalogEntry().Matches(Read(CatalogSource))) + { + required.Add(ids[match.Groups["name"].Value], Scenarios(match.Groups["arguments"].Value)); + } + + Assert.Equal(ids.Count, required.Count); + List offenders = []; + foreach (CapabilityTable table in ReadCapabilityTables()) + { + foreach (CapabilityRow row in table.Rows) + { + string[] documented = Scenarios(row.Qualification); + if (!required.TryGetValue(row.Id, out string[]? expected) || !documented.SequenceEqual(expected)) + { + offenders.Add($"{table.Path}:{row.Line} → {row.Id} names [{string.Join(", ", documented)}]"); + } + } + } + + Assert.True(offenders.Count == 0, + "The Qualification column must name exactly the scenarios that the catalog requires:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void InstallGuidesStateTheQualifiedHostProfile() + { + // The consumed package identity is the resolved CheatEngine.SDK entry of Core's lock file: the same source the + // Core build embeds for its runtime package gate. + using JsonDocument lockFile = JsonDocument.Parse(Read(CoreLockFile)); + string contentHash = lockFile.RootElement.GetProperty("dependencies").GetProperty("net10.0") + .GetProperty("CheatEngine.SDK").GetProperty("contentHash").GetString() + ?? throw new InvalidOperationException($"{CoreLockFile} has no CheatEngine.SDK content hash."); + + foreach (string guide in InstallGuides) + { + string text = Read(guide); + Assert.True(text.Contains(ProfileId, StringComparison.Ordinal), $"{guide} does not name the profile id."); + Assert.True(text.Contains(HostExecutableSha256, StringComparison.Ordinal), + $"{guide} does not state the SHA-256 of cheatengine-x86_64.exe."); + Assert.True(text.Contains(contentHash, StringComparison.Ordinal), + $"{guide} does not state the NuGet content hash that {CoreLockFile} locks for CheatEngine.SDK."); + Assert.True( + text.Split('\n').Any(static line => + line.Contains(RuntimeConfigurationSha256, StringComparison.Ordinal) && + line.Contains("local modification", StringComparison.OrdinalIgnoreCase)), + $"{guide} does not state the runtime configuration SHA-256 as a local modification."); + } + } + + /// + /// The Implementation label of an operational capability whose public API is experimental: its operational label + /// (for example "Operational adapter", or "Operational, policy opt-in" for an opt-in) followed by the diagnostic id. + /// + private static string ExperimentalImplementation(string operational, string diagnosticId) + { + return $"{operational}, experimental ({diagnosticId})"; + } + + /// + /// Reads every catalog row, each an operational adapter, keyed by capability id: the value is its experimental + /// diagnostic id, or for a stable API. + /// + private static Dictionary ReadExperimentalDiagnosticIds() + { + Dictionary ids = ReadCapabilityIds(); + Dictionary experimental = new(StringComparer.Ordinal); + foreach (Match match in ImplementationGate().Matches(Read(CatalogSource))) + { + string id = ids[match.Groups["name"].Value]; + Group diagnosticId = match.Groups["experimental"]; + Assert.True(experimental.TryAdd(id, diagnosticId.Success ? diagnosticId.Value : null), + $"{CatalogSource} describes {id} more than once."); + } + + Assert.Equal(ids.Count, experimental.Count); + return experimental; + } + + /// Reads ClientCapabilityId property names and their stable id strings. + private static Dictionary ReadCapabilityIds() + { + Dictionary ids = new(StringComparer.Ordinal); + foreach (Match match in CapabilityIdDeclaration().Matches(Read(CapabilityIdSource))) + { + ids.Add(match.Groups["name"].Value, match.Groups["id"].Value); + } + + return ids; + } + + private static List ReadCapabilityTables() + { + List tables = []; + foreach (string path in RepositoryRoot.EnumerateSourceFiles("*.md").Order(StringComparer.Ordinal)) + { + string[] lines = Read(path).Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + int start = Array.FindIndex(lines, static line => line.Trim() == StartMarker); + if (start < 0) + { + continue; + } + + int end = Array.FindIndex(lines, start + 1, static line => line.Trim() == EndMarker); + Assert.True(end > start, $"{path} opens a capability table without closing it."); + string header = Array.Find(lines[(start + 1)..end], static line => line.StartsWith('|')) ?? string.Empty; + string[] headers = header.Split('|'); + int qualification = Array.FindIndex(headers, static column => column.Trim() == QualificationColumn); + int status = Array.FindIndex(headers, static column => column.Trim() == StatusColumn); + Assert.True(qualification > 0, $"{path} has a capability table without a '{QualificationColumn}' column."); + List rows = []; + for (int index = start + 1; index < end; index++) + { + Match row = CapabilityRowPattern().Match(lines[index]); + if (row.Success) + { + string[] columns = lines[index].Split('|'); + string? statusCell = status > 0 ? Cell(columns, status) ?? string.Empty : null; + rows.Add(new CapabilityRow(row.Groups["id"].Value, row.Groups["implementation"].Value.Trim(), + Cell(columns, qualification) ?? string.Empty, statusCell, index + 1)); + } + } + + tables.Add(new CapabilityTable(path, rows)); + } + + return tables; + } + + /// The trimmed cell at , or past the row's end. + private static string? Cell(string[] columns, int index) + { + return index < columns.Length ? columns[index].Trim() : null; + } + + private static string[] Scenarios(string text) + { + return + [ + .. ScenarioId().Matches(text).Select(static match => match.Value).Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + ]; + } + + private static string Read(string relativePath) + { + return File.ReadAllText(Path.Combine(RepositoryRoot.Path, relativePath)); + } + + [GeneratedRegex("""public static ClientCapabilityId (?\w+) => new\("(?[^"]+)"\);""", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex CapabilityIdDeclaration(); + + [GeneratedRegex( + @"Entry\(ClientCapabilityId\.(?\w+),[^)]*\)(?:\s*with\s*\{\s*ExperimentalDiagnosticId\s*=\s*""(?[^""]+)""\s*\})?", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ImplementationGate(); + + [GeneratedRegex(@"Entry\(ClientCapabilityId\.(?\w+),(?[^)]*)\)", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex CatalogEntry(); + + [GeneratedRegex(@"Q\d{2}(?:\.[a-z])?", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ScenarioId(); + + [GeneratedRegex(@"^\|\s*`(?Client\.[A-Za-z]+)`\s*\|(?[^|]+)\|", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex CapabilityRowPattern(); + + private sealed record CapabilityTable(string Path, IReadOnlyList Rows); + + /// One capability row. + /// The status cell, or when the table has no status column. + private sealed record CapabilityRow( + string Id, + string Implementation, + string Qualification, + string? Status, + int Line); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/CheatEngine.Client.Repository.Tests.csproj b/tests/CheatEngine.Client.Repository.Tests/CheatEngine.Client.Repository.Tests.csproj new file mode 100644 index 0000000..19ef643 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/CheatEngine.Client.Repository.Tests.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/CodeQlWorkflowTests.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/CodeQlWorkflowTests.cs new file mode 100644 index 0000000..c3e7170 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/CodeQlWorkflowTests.cs @@ -0,0 +1,131 @@ +using CheatEngine.Client.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Advisory CodeQL (PR-CQ-23, CI-CLI-11, CI-BOTH-4): C# is analysed from a manual, traced build of the whole shipped +/// product graph, so source-generator output is analysed; GitHub Actions without a build; and nothing on that path +/// uses a dependency cache (shared-contracts §1.5). +/// +public sealed class CodeQlWorkflowTests +{ + private const string WorkflowPath = ".github/workflows/codeql.yml"; + private const string ProductProject = "src/CheatEngine.Client/CheatEngine.Client.csproj"; + private const string InitAction = "github/codeql-action/init"; + private const string AnalyzeAction = "github/codeql-action/analyze"; + private const string SetupAction = "./.github/actions/setup-dotnet"; + + private static readonly YamlMappingNode Workflow = GovernanceFile.LoadYaml(WorkflowPath); + + [Fact] + public void CSharpIsAnalysedWithAManualBuildOfTheShippedProductGraph() + { + YamlMappingNode job = GovernanceFile.Jobs(Workflow)["csharp"]; + IReadOnlyList steps = GovernanceFile.Steps(job); + int init = GovernanceFile.IndexOfAction(steps, InitAction); + int build = GovernanceFile.IndexOfRun(steps, "dotnet build"); + int analyze = GovernanceFile.IndexOfAction(steps, AnalyzeAction); + + Assert.True(init >= 0 && init < build && build < analyze, "The traced build must run between init and analyze."); + Assert.Equal("windows-2025", GovernanceFile.Scalar(job, "runs-on")); + Assert.Equal("csharp", GovernanceFile.With(steps[init], "languages")); + Assert.Equal("manual", GovernanceFile.With(steps[init], "build-mode")); + Assert.Equal(ProductProject, GovernanceFile.With(GovernanceFile.StepUsing(steps, SetupAction), "restore")); + + string script = GovernanceFile.Scalar(steps[build], "run")!.Replace("`", " ", StringComparison.Ordinal); + string command = GovernanceFile.NormalizeWhitespace(script); + string[] required = + [ + $"dotnet build {ProductProject}", "--configuration Release", "--no-restore", "--no-incremental", + "--disable-build-servers", "-p:UseSharedCompilation=false", "$LASTEXITCODE" + ]; + foreach (string fragment in required) + { + Assert.Contains(fragment, command, StringComparison.Ordinal); + } + } + + [Fact] + public void TheBuiltProjectReachesEveryShippedAssemblyAndTheSourceGenerator() + { + HashSet reached = new(StringComparer.Ordinal); + Queue pending = new([ProductProject]); + while (pending.TryDequeue(out string? project)) + { + if (!reached.Add(project)) + { + continue; + } + + string directory = Path.Combine(RepositoryRoot.Path, Path.GetDirectoryName(project)!); + foreach (XElement reference in XDocument.Load(GovernanceFile.FullPath(project)).Descendants("ProjectReference")) + { + string include = ((string) reference.Attribute("Include")!).Replace('\\', '/'); + pending.Enqueue(RepositoryRoot.ToRelative(Path.GetFullPath(Path.Combine(directory, include)))); + } + } + + List shipped = [.. RepositoryRoot.EnumerateSourceFiles("*.csproj").Where(IsShippedOrGenerator)]; + + Assert.NotEmpty(shipped); + Assert.All(shipped, project => Assert.Contains(project, reached)); + } + + [Fact] + public void CodeQlNeverUsesADependencyCache() + { + IReadOnlyList steps = GovernanceFile.Steps(GovernanceFile.Jobs(Workflow)["csharp"]); + YamlMappingNode init = GovernanceFile.StepUsing(steps, InitAction); + + Assert.Equal("false", GovernanceFile.With(init, "dependency-caching")); + Assert.Equal("false", GovernanceFile.With(init, "trap-caching")); + foreach (YamlMappingNode step in GovernanceFile.Jobs(Workflow).Values.SelectMany(GovernanceFile.Steps)) + { + string action = GovernanceFile.ActionName(step) ?? ""; + Assert.False(action.StartsWith("actions/cache", StringComparison.Ordinal), "CodeQL must not use actions/cache."); + Assert.NotEqual("true", GovernanceFile.With(step, "cache")); + } + } + + [Fact] + public void CodeQlAnalysesExactlyCSharpAndActions() + { + IReadOnlyDictionary jobs = GovernanceFile.Jobs(Workflow); + List languages = []; + List categories = []; + foreach (YamlMappingNode job in jobs.Values) + { + IReadOnlyList steps = GovernanceFile.Steps(job); + languages.Add(GovernanceFile.With(GovernanceFile.StepUsing(steps, InitAction), "languages") ?? ""); + categories.Add(GovernanceFile.With(GovernanceFile.StepUsing(steps, AnalyzeAction), "category") ?? ""); + Assert.Equal("write", GovernanceFile.Permissions(job)["security-events"]); + Assert.Null(GovernanceFile.Child(job, "strategy")); + } + + YamlMappingNode actionsInit = GovernanceFile.StepUsing(GovernanceFile.Steps(jobs["actions"]), InitAction); + Assert.Equal(["actions", "csharp"], languages.Order(StringComparer.Ordinal)); + Assert.Equal(["/language:actions", "/language:csharp"], categories.Order(StringComparer.Ordinal)); + Assert.Equal("none", GovernanceFile.With(actionsInit, "build-mode")); + Assert.Equal(new Dictionary { ["contents"] = "read" }, GovernanceFile.Permissions(Workflow)); + } + + [Fact] + public void CodeQlHasNoMergeGroupTrigger() + { + YamlMappingNode triggers = GovernanceFile.Triggers(Workflow); + + Assert.False(GovernanceFile.Has(triggers, "merge_group")); + Assert.False(GovernanceFile.Has(triggers, "pull_request_target")); + Assert.True(GovernanceFile.Has(triggers, "pull_request")); + Assert.Equal(["main"], GovernanceFile.PushBranches(Workflow)); + Assert.NotNull(GovernanceFile.Sequence(triggers, "schedule")); + } + + private static bool IsShippedOrGenerator(string project) + { + return project.StartsWith("libs/", StringComparison.Ordinal) + || project.StartsWith("source-generators/", StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/CommunityHealthTests.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/CommunityHealthTests.cs new file mode 100644 index 0000000..34a1b98 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/CommunityHealthTests.cs @@ -0,0 +1,157 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Community health files (PR-CQ-37, CI-BOTH-7): the security policy routes vulnerabilities to GitHub private +/// vulnerability reporting, the code of conduct names a private channel, and the informational CODEOWNERS file uses +/// only syntax GitHub understands and paths that exist with their exact case. No local path anywhere (ADR-12). +/// +public sealed class CommunityHealthTests +{ + private const string ClientAdvisories = "https://github.com/CheatEngineNet/CheatEngine.Client/security/advisories/new"; + private const string SdkAdvisories = "https://github.com/CheatEngineNet/CheatEngine.SDK/security/advisories/new"; + private const string CodeOwnersPath = ".github/CODEOWNERS"; + + private static readonly Regex LocalPath = new( + @"[A-Za-z]:\\|\\Users\\|/Users/|file://", + RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(1)); + + private static readonly Regex EmailAddress = new( + @"[\w.+-]+@[\w-]+\.[\w.]+", + RegexOptions.None, + TimeSpan.FromSeconds(1)); + + /// Owners allowed in CODEOWNERS. Co-owners are a maintainer decision (orchestrator decision O5). + private static readonly HashSet KnownOwners = new(StringComparer.Ordinal) { "@AriusII" }; + + [Fact] + public void SecurityPolicyPointsToPrivateVulnerabilityReporting() + { + string policy = GovernanceFile.ReadText("SECURITY.md"); + string[] headings = ["## Supported versions", "## Reporting a vulnerability", "## What to expect", "## Scope"]; + + Assert.All(headings, heading => Assert.Contains(heading, policy, StringComparison.Ordinal)); + Assert.Contains(ClientAdvisories, policy, StringComparison.Ordinal); + Assert.Contains(SdkAdvisories, policy, StringComparison.Ordinal); + Assert.Contains("Never report a vulnerability in a public issue", policy, StringComparison.Ordinal); + Assert.Contains("contentHash", policy, StringComparison.Ordinal); + Assert.Contains("cheatengine-sdk-lua-bridge.dll", policy, StringComparison.Ordinal); + Assert.Contains("within 7 days", policy, StringComparison.Ordinal); + } + + [Fact] + public void SecurityPolicyContainsNoLocalPath() + { + List offending = []; + foreach (string file in GovernanceDocuments()) + { + offending.AddRange(LocalPath.Matches(GovernanceFile.ReadText(file)).Select(match => $"{file}: '{match.Value}'")); + } + + Assert.True(offending.Count == 0, + $"Governance documents must stay usable outside any workspace (ADR-12): {string.Join("; ", offending)}"); + } + + [Fact] + public void CodeOfConductIsTheContributorCovenantWithAPrivateContact() + { + string conduct = GovernanceFile.ReadText("CODE_OF_CONDUCT.md"); + + Assert.StartsWith("# Contributor Covenant Code of Conduct", conduct, StringComparison.Ordinal); + Assert.Contains("version 2.1", conduct, StringComparison.Ordinal); + Assert.DoesNotContain("[INSERT CONTACT METHOD]", conduct, StringComparison.Ordinal); + Assert.Contains(ClientAdvisories, conduct, StringComparison.Ordinal); + Assert.DoesNotMatch(EmailAddress, conduct); + } + + [Fact] + public void OnlyOneCodeOwnersFileExists() + { + Assert.Equal([CodeOwnersPath], RepositoryRoot.EnumerateSourceFiles("CODEOWNERS")); + } + + [Fact] + public void CodeOwnersUsesSupportedSyntax() + { + List<(string Pattern, string[] Owners)> rules = ReadCodeOwners(); + + Assert.NotEmpty(rules); + Assert.Equal("*", rules[0].Pattern); + Assert.Equal(rules.Count, rules.Select(rule => rule.Pattern).Distinct(StringComparer.Ordinal).Count()); + foreach ((string pattern, string[] owners) in rules) + { + bool supported = pattern.IndexOfAny(['!', '[', ']']) < 0 && !pattern.Contains(@"\#", StringComparison.Ordinal); + Assert.True(supported, $"CODEOWNERS pattern '{pattern}' uses syntax GitHub does not support (!, [ ], \\#)."); + Assert.NotEmpty(owners); + Assert.All(owners, owner => Assert.Contains(owner, KnownOwners)); + } + } + + [Fact] + public void CodeOwnersNamesOnlyExistingPaths() + { + List missing = []; + foreach ((string pattern, _) in ReadCodeOwners().Where(rule => rule.Pattern != "*")) + { + Assert.StartsWith("/", pattern, StringComparison.Ordinal); + if (!ExistsWithExactCase(pattern.Trim('/'))) + { + missing.Add(pattern); + } + } + + Assert.True(missing.Count == 0, $"CODEOWNERS paths missing with this exact case: {string.Join(", ", missing)}"); + } + + private static IEnumerable GovernanceDocuments() + { + yield return "SECURITY.md"; + yield return "CODE_OF_CONDUCT.md"; + yield return CodeOwnersPath; + foreach (string form in Directory.EnumerateFiles(GovernanceFile.FullPath(".github/ISSUE_TEMPLATE"), "*.yml")) + { + yield return RepositoryRoot.ToRelative(form); + } + } + + private static List<(string Pattern, string[] Owners)> ReadCodeOwners() + { + List<(string, string[])> rules = []; + foreach (string rawLine in GovernanceFile.ReadText(CodeOwnersPath).Split('\n')) + { + 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..])); + } + + return rules; + } + + /// Case-sensitive existence check: Windows file systems ignore case, GitHub does not. + private static bool ExistsWithExactCase(string relativePath) + { + string current = RepositoryRoot.Path; + foreach (string segment in relativePath.Split('/')) + { + string? next = Directory.EnumerateFileSystemEntries(current) + .FirstOrDefault(entry => string.Equals(Path.GetFileName(entry), segment, StringComparison.Ordinal)); + if (next is null) + { + return false; + } + + current = next; + } + + return true; + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/DependabotConfigurationTests.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/DependabotConfigurationTests.cs new file mode 100644 index 0000000..dc3f004 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/DependabotConfigurationTests.cs @@ -0,0 +1,186 @@ +using System.Globalization; + +using CheatEngine.Client.Repository.Tests.Packaging; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Hardened Dependabot configuration (PR-CQ-08, A21-36, CI-BOTH-6): a cooldown on every ecosystem (zizmor +/// dependabot-cooldown threshold: 7 days, https://docs.zizmor.sh/audits/#dependabot-cooldown), NuGet, GitHub Actions +/// (workflows and composite actions) and the .NET SDK covered, and the ignores that protect frozen decisions: the +/// Client stays on the CheatEngine.SDK major of its pin (eng/CheatEngineSdk.props), a CheatEngine.SDK update is never +/// grouped with other packages, Roslyn moves with the generator floor, SDK-implicit packages move with global.json. +/// Options: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference +/// +public sealed class DependabotConfigurationTests +{ + private const string ConfigurationPath = ".github/dependabot.yml"; + private const int MinimumCooldownDays = 7; + + private static readonly YamlMappingNode Configuration = GovernanceFile.LoadYaml(ConfigurationPath); + + [Fact] + public void EveryEcosystemWaitsAtLeastSevenDays() + { + foreach (YamlMappingNode update in Updates()) + { + YamlMappingNode cooldown = Assert.IsType(GovernanceFile.Child(update, "cooldown")); + Assert.NotNull(GovernanceFile.Scalar(cooldown, "default-days")); + foreach (KeyValuePair entry in cooldown.Children) + { + string key = ((YamlScalarNode) entry.Key).Value!; + if (key.EndsWith("-days", StringComparison.Ordinal)) + { + int days = int.Parse(((YamlScalarNode) entry.Value).Value!, NumberStyles.None, CultureInfo.InvariantCulture); + Assert.True(days >= MinimumCooldownDays, $"{Ecosystem(update)}: cooldown {key} is only {days} days."); + } + } + } + } + + [Fact] + public void DependabotCoversNuGetActionsAndTheDotNetSdk() + { + Assert.Equal("2", GovernanceFile.Scalar(Configuration, "version")); + Assert.Equal(["dotnet-sdk", "github-actions", "nuget"], Updates().Select(Ecosystem).Order(StringComparer.Ordinal)); + Assert.Equal("/", GovernanceFile.Scalar(Update("nuget"), "directory")); + Assert.Equal("/", GovernanceFile.Scalar(Update("dotnet-sdk"), "directory")); + Assert.All(Updates(), update => Assert.NotNull(GovernanceFile.Mapping(update, "schedule"))); + } + + [Fact] + public void CompositeActionsAreUpdatedWithTheWorkflows() + { + YamlMappingNode actions = Update("github-actions"); + + Assert.Null(GovernanceFile.Child(actions, "directory")); + Assert.Equal(["/", "/.github/actions/*"], GovernanceFile.Strings(GovernanceFile.Child(actions, "directories"))); + } + + [Fact] + public void CheatEngineSdkMajorUpdatesAreIgnored() + { + YamlMappingNode ignore = Ignore("nuget", "CheatEngine.SDK"); + + Assert.Equal(["version-update:semver-major"], GovernanceFile.Strings(GovernanceFile.Child(ignore, "update-types"))); + Assert.Null(GovernanceFile.Child(ignore, "versions")); + } + + [Fact] + public void CheatEngineSdkIgnoreStatesThePinnedRangeAndTheFirstIgnoredMajor() + { + // A semver-major ignore is relative to the version in the repository, so it needs no edit at a major migration; + // its comment does, and this keeps the comment in step with the pin (eng/CheatEngineSdk.props, "Major migration"). + string text = GovernanceFile.ReadText(ConfigurationPath); + + Assert.Contains($"CheatEngine.SDK {SdkPin.SupportedMajor}.x", text, StringComparison.Ordinal); + Assert.Contains($"[{SdkPin.Version},{SdkPin.UpperBound})", text, StringComparison.Ordinal); + Assert.Contains($"({SdkPin.UpperBound} and later) is ignored", text, StringComparison.Ordinal); + } + + [Fact] + public void CheatEngineSdkUpdatesAreNeverGrouped() + { + // A CheatEngine.SDK update, version or security, moves the reviewed pin and the lower bound of the SDK range + // the Client packages publish; it passes only with the bump procedure of eng/CheatEngineSdk.props, so every + // NuGet group excludes it and it never holds a grouped pull request back. + YamlMappingNode groups = Assert.IsType(GovernanceFile.Child(Update("nuget"), "groups")); + + List groupsThatTakeTheSdk = []; + foreach (KeyValuePair group in groups.Children) + { + YamlMappingNode definition = Assert.IsType(group.Value); + IReadOnlyList excluded = + GovernanceFile.Strings(GovernanceFile.Child(definition, "exclude-patterns")); + if (!excluded.Contains(SdkPin.PackageId, StringComparer.Ordinal)) + { + groupsThatTakeTheSdk.Add(((YamlScalarNode) group.Key).Value!); + } + } + + Assert.NotEmpty(groups.Children); + Assert.Empty(groupsThatTakeTheSdk); + } + + [Fact] + public void RoslynAndSdkImplicitPackagesAreIgnored() + { + string[] pinned = + [ + "Microsoft.CodeAnalysis.CSharp", + "Microsoft.CodeAnalysis.Analyzers", + "Microsoft.NET.ILLink.Tasks", + "Microsoft.DotNet.ILCompiler", + "runtime.*.Microsoft.DotNet.ILCompiler" + ]; + + foreach (string name in pinned) + { + Assert.True(Ignore("nuget", name).Children.Count == 1, $"'{name}' must be ignored for every update and version."); + } + } + + [Fact] + public void DotNetSdkMajorUpdatesAreIgnored() + { + YamlMappingNode ignore = Assert.Single(Ignores("dotnet-sdk")); + + Assert.Equal("*", GovernanceFile.Scalar(ignore, "dependency-name")); + Assert.Equal(["version-update:semver-major"], GovernanceFile.Strings(GovernanceFile.Child(ignore, "update-types"))); + } + + [Fact] + public void NoCommitMessagePrefixIsConfigured() + { + // A prefix produces "deps: ..." subjects, which the pull request title policy forbids. + Assert.All(Updates(), update => Assert.False(GovernanceFile.Has(update, "commit-message"), Ecosystem(update))); + } + + [Fact] + public void DependabotNeverAllowsExternalCodeExecution() + { + string configuration = GovernanceFile.ReadText(ConfigurationPath); + + Assert.DoesNotContain("insecure-external-code-execution", configuration, StringComparison.Ordinal); + Assert.False(GovernanceFile.Has(Configuration, "registries")); + } + + [Fact] + public void DependabotLabelsAreKnownRepositoryLabels() + { + foreach (YamlMappingNode update in Updates()) + { + IReadOnlyList labels = GovernanceFile.Strings(GovernanceFile.Child(update, "labels")); + + Assert.Contains("dependencies", labels); + Assert.All(labels, label => Assert.Contains(label, RepositoryLabels.Known)); + } + } + + private static IReadOnlyList Updates() + { + return [.. (GovernanceFile.Sequence(Configuration, "updates")?.Children ?? []).Cast()]; + } + + private static YamlMappingNode Update(string ecosystem) + { + return Assert.Single(Updates(), update => Ecosystem(update) == ecosystem); + } + + private static IReadOnlyList Ignores(string ecosystem) + { + return [.. (GovernanceFile.Sequence(Update(ecosystem), "ignore")?.Children ?? []).Cast()]; + } + + private static YamlMappingNode Ignore(string ecosystem, string dependency) + { + return Assert.Single(Ignores(ecosystem), entry => GovernanceFile.Scalar(entry, "dependency-name") == dependency); + } + + private static string Ecosystem(YamlMappingNode update) + { + return GovernanceFile.Scalar(update, "package-ecosystem") ?? string.Empty; + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/GovernanceFile.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/GovernanceFile.cs new file mode 100644 index 0000000..d6cc62f --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/GovernanceFile.cs @@ -0,0 +1,198 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Reads the committed governance files (workflows, Dependabot, issue forms, scripts) for the Governance tests. YAML is +/// loaded with YamlDotNet's representation model, so the key on stays the string "on" and quoted scalars stay +/// strings. Comments are dropped by YAML parsing; tests that need them read the raw text. +/// +internal static class GovernanceFile +{ + /// Absolute path of a repository-relative file. + public static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath); + } + + /// The raw text of a repository-relative file. + public static string ReadText(string relativePath) + { + string path = FullPath(relativePath); + Assert.True(File.Exists(path), $"'{relativePath}' does not exist."); + return File.ReadAllText(path); + } + + /// The root mapping of a repository-relative YAML file. + public static YamlMappingNode LoadYaml(string relativePath) + { + YamlStream stream = []; + using (StringReader reader = new(ReadText(relativePath))) + { + stream.Load(reader); + } + + Assert.True(stream.Documents.Count == 1, $"'{relativePath}' must contain exactly one YAML document."); + return Assert.IsType(stream.Documents[0].RootNode); + } + + /// The child mapping at , or null when absent or not a mapping. + public static YamlMappingNode? Mapping(YamlMappingNode node, string key) + { + return Child(node, key) as YamlMappingNode; + } + + /// The child sequence at , or null when absent or not a sequence. + public static YamlSequenceNode? Sequence(YamlMappingNode node, string key) + { + return Child(node, key) as YamlSequenceNode; + } + + /// The scalar value at , or null when absent or not a scalar. + public static string? Scalar(YamlMappingNode node, string key) + { + return (Child(node, key) as YamlScalarNode)?.Value; + } + + /// Whether the mapping has , whatever its value (a bare trigger is null). + public static bool Has(YamlMappingNode node, string key) + { + return node.Children.ContainsKey(new YamlScalarNode(key)); + } + + /// The child node at , or null. + public static YamlNode? Child(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) ? value : null; + } + + /// The strings of a sequence, or a one-element list for a scalar (keys such as needs). + public static IReadOnlyList Strings(YamlNode? node) + { + return node switch + { + YamlScalarNode scalar when scalar.Value is not null => [scalar.Value], + YamlSequenceNode sequence => [.. sequence.Children.OfType().Select(item => item.Value ?? "")], + _ => [] + }; + } + + /// The keys of a mapping, in file order. + public static IReadOnlyList Keys(YamlMappingNode node) + { + return [.. node.Children.Keys.Select(key => ((YamlScalarNode) key).Value ?? "")]; + } + + /// The single step that uses (owner/repo[/path] or ./path). + public static YamlMappingNode StepUsing(IReadOnlyList steps, string action) + { + return Assert.Single(steps, step => ActionName(step) == action); + } + + /// The index of the first step that uses , or -1. + public static int IndexOfAction(IReadOnlyList steps, string action) + { + return IndexOf(steps, step => ActionName(step) == action); + } + + /// The index of the first run step whose script contains , or -1. + public static int IndexOfRun(IReadOnlyList steps, string fragment) + { + return IndexOf(steps, step => Scalar(step, "run")?.Contains(fragment, StringComparison.Ordinal) == true); + } + + /// The branch filter of the push trigger. + public static IReadOnlyList PushBranches(YamlMappingNode workflow) + { + YamlMappingNode push = Mapping(Triggers(workflow), "push") ?? throw new InvalidOperationException("No push trigger."); + return Strings(Child(push, "branches")); + } + + /// The if condition of a job or step with its whitespace normalized (empty when absent). + public static string Condition(YamlMappingNode jobOrStep) + { + return NormalizeWhitespace(Scalar(jobOrStep, "if") ?? ""); + } + + /// The jobs mapping of a workflow, keyed by job id. + public static IReadOnlyDictionary Jobs(YamlMappingNode workflow) + { + YamlMappingNode jobs = Mapping(workflow, "jobs") ?? throw new InvalidOperationException("The workflow has no jobs."); + Dictionary result = new(StringComparer.Ordinal); + foreach (KeyValuePair job in jobs.Children) + { + result.Add(((YamlScalarNode) job.Key).Value!, (YamlMappingNode) job.Value); + } + + return result; + } + + /// The steps of a job (empty for a job that calls a reusable workflow). + public static IReadOnlyList Steps(YamlMappingNode job) + { + return [.. (Sequence(job, "steps")?.Children ?? []).Cast()]; + } + + /// The on mapping of a workflow (a bare trigger list is not used in this repository). + public static YamlMappingNode Triggers(YamlMappingNode workflow) + { + return Mapping(workflow, "on") ?? throw new InvalidOperationException("The workflow has no 'on' mapping."); + } + + /// The permission map of a workflow or job (empty when absent or {}). + public static IReadOnlyDictionary Permissions(YamlMappingNode workflowOrJob) + { + Dictionary result = new(StringComparer.Ordinal); + if (Mapping(workflowOrJob, "permissions") is { } permissions) + { + foreach (KeyValuePair entry in permissions.Children) + { + result.Add(((YamlScalarNode) entry.Key).Value!, ((YamlScalarNode) entry.Value).Value ?? string.Empty); + } + } + + return result; + } + + /// The owner/repo[/path] part of a step's uses, or null for a run step. + public static string? ActionName(YamlMappingNode step) + { + string? uses = Scalar(step, "uses"); + if (uses is null) + { + return null; + } + + int at = uses.IndexOf('@', StringComparison.Ordinal); + return at < 0 ? uses : uses[..at]; + } + + /// The with input of a step, or null. + public static string? With(YamlMappingNode step, string input) + { + return Mapping(step, "with") is { } with ? Scalar(with, input) : null; + } + + /// Collapses every whitespace run to one space (folded if: >- scalars). + public static string NormalizeWhitespace(string value) + { + return Regex.Replace(value, @"\s+", " ", RegexOptions.None, TimeSpan.FromSeconds(1)).Trim(); + } + + private static int IndexOf(IReadOnlyList steps, Func predicate) + { + for (int index = 0; index < steps.Count; index++) + { + if (predicate(steps[index])) + { + return index; + } + } + + return -1; + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/IssueFormTests.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/IssueFormTests.cs new file mode 100644 index 0000000..9ff18f6 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/IssueFormTests.cs @@ -0,0 +1,343 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; +using CheatEngine.Client.Repository.Tests.Packaging; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Issue forms (A22-44, audit Checkpoint F: "an issue of compatibility can be tied to a precise tuple"). The +/// compatibility form requires every element of the release tuple; every form follows GitHub's form schema; the version +/// placeholders name the Client line and the pinned CheatEngine.SDK; blank issues are disabled and vulnerabilities are +/// routed to private reporting. Schema: +/// https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms +/// +public sealed class IssueFormTests +{ + private const string TemplateFolder = ".github/ISSUE_TEMPLATE"; + private const string CompatibilityForm = TemplateFolder + "/compatibility.yml"; + + /// + /// The tuple a compatibility report must identify, from audit ch.21 l.15 (Client version, SDK package and its + /// fingerprint, bridge, the Lua DLL actually used, the exact Cheat Engine and its runtime policy, the important build + /// options and the load profile) and the Client tuple of shared-contracts §2.5, plus where the problem was observed + /// (ch.20 levels C0-C4) and the target. + /// + private static readonly string[] RequiredTuple = + [ + "client-version", + "sdk-version", + "sdk-content-hash", + "bridge-sha256", + "lua-dll-sha256", + "ce-version", + "ce-exe-sha256", + "ce-build", + "runtimeconfig-sha256", + "runtimeconfig-state", + "dotnet-runtimes", + "load-profile", + "plugin-build", + "observed-level", + "target", + "os" + ]; + + private static readonly HashSet ElementTypes = new(StringComparer.Ordinal) + { + "markdown", + "textarea", + "input", + "dropdown", + "checkboxes" + }; + + private static readonly Regex ElementId = new("^[A-Za-z0-9_-]+$", RegexOptions.None, TimeSpan.FromSeconds(1)); + + /// "qualified profile/build/route…", "(qualified", "is/are/was qualified", "host-qualified" (negations pass). + private static readonly Regex QualifiedClaim = new( + @"\bqualified\s+(profile|build|route|setup|configuration)\b|\(\s*qualified\b|\b(is|are|was)\s+(host-)?qualified\b|(? elements = ElementsById(GovernanceFile.LoadYaml(CompatibilityForm)); + List problems = []; + foreach (string id in RequiredTuple) + { + if (!elements.TryGetValue(id, out YamlMappingNode? element)) + { + problems.Add($"'{id}' is missing"); + } + else if (!IsRequired(element)) + { + problems.Add($"'{id}' is not required"); + } + } + + Assert.True(problems.Count == 0, $"{CompatibilityForm}: {string.Join("; ", problems)}."); + Assert.Equal(["(C0)", "(C1)", "(C2)", "(C3)", "(C4)"], Options(elements["observed-level"]).Select(LevelSuffix)); + Assert.True(elements.ContainsKey("bridge-fingerprint") && !IsRequired(elements["bridge-fingerprint"])); + } + + [Fact] + public void VersionPlaceholdersNameTheClientLineAndThePinnedSdk() + { + // A reporter copies these examples, so each one must be able to exist: a stable Client version of the current + // major line (MinVerMinimumMajorMinor), exactly the pinned CheatEngine.SDK (eng/CheatEngineSdk.props), and the + // content hash the lock files record for it, abbreviated as "...". + string clientMajor = PackageVersioningTests.BuildProperty("MinVerMinimumMajorMinor").Split('.')[0]; + string contentHash = PinnedSdkContentHash(); + List problems = []; + int placeholders = 0; + foreach (string form in IssueForms()) + { + foreach ((string id, YamlMappingNode element) in ElementsById(GovernanceFile.LoadYaml(form))) + { + string placeholder = Placeholder(element) ?? string.Empty; + if (id is "client-version" or "sdk-version" or "sdk-content-hash") + { + placeholders++; + } + + string? problem = id switch + { + "client-version" when !IsStableVersionOfMajor(placeholder, clientMajor) => + $"is not a stable {clientMajor}.x.y version", + "sdk-version" when placeholder != SdkPin.Version => $"is not the pinned {SdkPin.Version}", + "sdk-content-hash" when !Abbreviates(placeholder, contentHash) => + "does not abbreviate the pinned content hash", + _ => null + }; + if (problem is not null) + { + problems.Add($"{form}: the {id} placeholder '{placeholder}' {problem}"); + } + } + } + + Assert.True(placeholders >= 5, + $"Expected the version placeholders of the bug and compatibility forms, found {placeholders}."); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void IssueFormsFollowGitHubFormSyntax() + { + List problems = []; + HashSet names = new(StringComparer.Ordinal); + foreach (string form in IssueForms()) + { + YamlMappingNode root = GovernanceFile.LoadYaml(form); + foreach (string key in new[] { "name", "description" }) + { + if (string.IsNullOrWhiteSpace(GovernanceFile.Scalar(root, key))) + { + problems.Add($"{form}: top-level '{key}' is missing"); + } + } + + if (!names.Add(GovernanceFile.Scalar(root, "name") ?? string.Empty)) + { + problems.Add($"{form}: the form name is not unique"); + } + + YamlSequenceNode? body = GovernanceFile.Sequence(root, "body"); + if (body is null || body.Children.Count == 0) + { + problems.Add($"{form}: 'body' is missing or empty"); + continue; + } + + HashSet ids = new(StringComparer.Ordinal); + foreach (YamlMappingNode element in body.Children.Cast()) + { + problems.AddRange(ElementProblems(form, element, ids)); + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void BlankIssuesAreDisabledAndVulnerabilitiesGoToPrivateReporting() + { + YamlMappingNode config = GovernanceFile.LoadYaml(TemplateFolder + "/config.yml"); + List urls = [.. (GovernanceFile.Sequence(config, "contact_links")?.Children ?? []) + .Cast() + .Select(link => GovernanceFile.Scalar(link, "url") ?? string.Empty)]; + + Assert.Equal("false", GovernanceFile.Scalar(config, "blank_issues_enabled")); + Assert.Contains("https://github.com/CheatEngineNet/CheatEngine.Client/security/advisories/new", urls); + Assert.Contains("https://github.com/CheatEngineNet/CheatEngine.SDK/issues/new/choose", urls); + Assert.All(urls, url => Assert.StartsWith("https://github.com/", url, StringComparison.Ordinal)); + Assert.All(IssueForms(), form => + Assert.Contains("private vulnerability reporting", GovernanceFile.ReadText(form), StringComparison.Ordinal)); + } + + [Fact] + public void NoIssueFormPresentsAProfileAsQualified() + { + // Evidence discipline (audit ch.22 arbitration "Un test non exécuté reste non exécuté"): the managed hostfxr + // profile is only qualifiable until host receipts exist, so a form may target it but never call it qualified. + foreach (string form in Directory.EnumerateFiles(GovernanceFile.FullPath(TemplateFolder), "*.yml").Select(RepositoryRoot.ToRelative)) + { + Match claim = QualifiedClaim.Match(GovernanceFile.ReadText(form)); + Assert.False(claim.Success, $"{form} presents something as qualified ('{claim.Value}'); say 'targeted for qualification'."); + } + } + + [Fact] + public void IssueFormLabelsAreKnownRepositoryLabels() + { + foreach (string form in IssueForms()) + { + IReadOnlyList labels = GovernanceFile.Strings(GovernanceFile.Child(GovernanceFile.LoadYaml(form), "labels")); + + Assert.NotEmpty(labels); + Assert.All(labels, label => Assert.Contains(label, RepositoryLabels.Known)); + } + } + + private static IEnumerable IssueForms() + { + return Directory.EnumerateFiles(GovernanceFile.FullPath(TemplateFolder), "*.yml") + .Select(RepositoryRoot.ToRelative) + .Where(path => !path.EndsWith("/config.yml", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal); + } + + private static IEnumerable ElementProblems(string form, YamlMappingNode element, HashSet ids) + { + string type = GovernanceFile.Scalar(element, "type") ?? string.Empty; + string? id = GovernanceFile.Scalar(element, "id"); + YamlMappingNode? attributes = GovernanceFile.Mapping(element, "attributes"); + if (!ElementTypes.Contains(type)) + { + yield return $"{form}: unknown element type '{type}'"; + } + + if (attributes is null) + { + yield return $"{form}: an element of type '{type}' has no attributes"; + yield break; + } + + if (type == "markdown") + { + if (id is not null || GovernanceFile.Has(element, "validations")) + { + yield return $"{form}: a markdown element must not have an id or validations"; + } + + yield break; + } + + if (id is null || !ElementId.IsMatch(id) || !ids.Add(id)) + { + yield return $"{form}: element id '{id}' is missing, invalid or duplicated"; + } + + if (string.IsNullOrWhiteSpace(GovernanceFile.Scalar(attributes, "label"))) + { + yield return $"{form}: element '{id}' has no label"; + } + + if (type == "dropdown") + { + IReadOnlyList options = Options(element); + bool unique = options.Distinct(StringComparer.Ordinal).Count() == options.Count; + if (options.Count == 0 || options.Any(string.IsNullOrWhiteSpace) || !unique) + { + yield return $"{form}: dropdown '{id}' needs unique, non-empty options"; + } + } + + if (type == "checkboxes") + { + IEnumerable checkboxes = GovernanceFile.Sequence(attributes, "options")?.Children ?? []; + foreach (YamlMappingNode option in checkboxes.Cast()) + { + if (string.IsNullOrWhiteSpace(GovernanceFile.Scalar(option, "label")) + || GovernanceFile.Scalar(option, "required") is not (null or "true" or "false")) + { + yield return $"{form}: checkbox option of '{id}' needs a label and a boolean 'required'"; + } + } + } + } + + private static string LevelSuffix(string option) + { + return option[option.LastIndexOf('(')..]; + } + + private static Dictionary ElementsById(YamlMappingNode form) + { + Dictionary elements = new(StringComparer.Ordinal); + foreach (YamlMappingNode element in (GovernanceFile.Sequence(form, "body")?.Children ?? []).Cast()) + { + if (GovernanceFile.Scalar(element, "id") is { } id) + { + elements.Add(id, element); + } + } + + return elements; + } + + private static bool IsRequired(YamlMappingNode element) + { + return GovernanceFile.Mapping(element, "validations") is { } validations + && GovernanceFile.Scalar(validations, "required") == "true"; + } + + private static IReadOnlyList Options(YamlMappingNode element) + { + return GovernanceFile.Strings(GovernanceFile.Mapping(element, "attributes") is { } attributes + ? GovernanceFile.Child(attributes, "options") + : null); + } + + private static string? Placeholder(YamlMappingNode element) + { + return GovernanceFile.Mapping(element, "attributes") is { } attributes + ? GovernanceFile.Scalar(attributes, "placeholder") + : null; + } + + private static bool IsStableVersionOfMajor(string version, string major) + { + string[] segments = version.Split('.'); + return segments.Length == 3 && segments[0] == major + && segments.All(static segment => segment.Length > 0 && segment.All(char.IsAsciiDigit)); + } + + /// Whether is "<start>...<end>" of . + private static bool Abbreviates(string abbreviation, string value) + { + string[] parts = abbreviation.Split("..."); + return parts.Length == 2 && parts[0].Length >= 8 && parts[1].Length > 0 + && value.StartsWith(parts[0], StringComparison.Ordinal) && value.EndsWith(parts[1], StringComparison.Ordinal); + } + + /// The content hash the lock files record for the pinned CheatEngine.SDK (one value, per SdkPinTests). + private static string PinnedSdkContentHash() + { + using JsonDocument lockFile = SdkPin.ReadJson("libs/CheatEngine.Client.Core/packages.lock.json"); + string[] hashes = + [ + .. lockFile.RootElement.GetProperty("dependencies").EnumerateObject() + .Where(static framework => framework.Value.TryGetProperty(SdkPin.PackageId, out _)) + .Select(static framework => framework.Value.GetProperty(SdkPin.PackageId).GetProperty("contentHash")) + .Select(static contentHash => contentHash.GetString() ?? string.Empty) + .Distinct(StringComparer.Ordinal) + ]; + return Assert.Single(hashes); + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/OnlineZizmorWorkflowTests.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/OnlineZizmorWorkflowTests.cs new file mode 100644 index 0000000..ef3d8ca --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/OnlineZizmorWorkflowTests.cs @@ -0,0 +1,61 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Advisory online zizmor audits (PR-CQ-21): the same zizmor version as the blocking offline run of the CI Gate, online +/// audits with SARIF upload to code scanning, never from a fork pull request (read-only token). +/// +public sealed class OnlineZizmorWorkflowTests +{ + private const string WorkflowPath = ".github/workflows/zizmor-online.yml"; + private const string ZizmorAction = "zizmorcore/zizmor-action"; + + private static readonly YamlMappingNode Workflow = GovernanceFile.LoadYaml(WorkflowPath); + + [Fact] + public void OnlineZizmorPinsTheSameVersionAsTheGate() + { + List gateSteps = [.. GovernanceFile.Jobs(GovernanceFile.LoadYaml(".github/workflows/ci.yml")).Values + .SelectMany(GovernanceFile.Steps) + .Where(step => GovernanceFile.ActionName(step) == ZizmorAction)]; + + Assert.True(gateSteps.Count == 1, + $"ci.yml must run {ZizmorAction} once (offline, lint job, shared-contracts §1.6); found {gateSteps.Count}."); + YamlMappingNode gateStep = gateSteps[0]; + string gateVersion = GovernanceFile.With(gateStep, "version") ?? string.Empty; + Assert.Matches(@"^\d+\.\d+\.\d+$", gateVersion); + Assert.Equal(gateVersion, GovernanceFile.With(ZizmorStep(), "version")); + Assert.Equal(GovernanceFile.Scalar(gateStep, "uses"), GovernanceFile.Scalar(ZizmorStep(), "uses")); + } + + [Fact] + public void OnlineZizmorRunsOnlineAuditsWithSarif() + { + YamlMappingNode step = ZizmorStep(); + YamlMappingNode job = Assert.Single(GovernanceFile.Jobs(Workflow)).Value; + + Assert.Equal("true", GovernanceFile.With(step, "online-audits")); + Assert.Equal("true", GovernanceFile.With(step, "advanced-security")); + Assert.Equal(".github/zizmor.yml", GovernanceFile.With(step, "config")); + Assert.Equal("write", GovernanceFile.Permissions(job)["security-events"]); + Assert.Null(GovernanceFile.Child(job, "continue-on-error")); + } + + [Fact] + public void OnlineZizmorNeverUploadsFromForks() + { + YamlMappingNode job = Assert.Single(GovernanceFile.Jobs(Workflow)).Value; + + Assert.Equal( + "github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository", + GovernanceFile.Condition(job)); + Assert.False(GovernanceFile.Has(GovernanceFile.Triggers(Workflow), "pull_request_target")); + } + + private static YamlMappingNode ZizmorStep() + { + YamlMappingNode job = Assert.Single(GovernanceFile.Jobs(Workflow)).Value; + return Assert.Single(GovernanceFile.Steps(job), step => GovernanceFile.ActionName(step) == ZizmorAction); + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/RepositoryLabels.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/RepositoryLabels.cs new file mode 100644 index 0000000..8c19708 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/RepositoryLabels.cs @@ -0,0 +1,36 @@ +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Labels that exist in the GitHub repository (read with gh label list on 2026-09-23). Dependabot and issue +/// forms silently ignore an unknown label, so every label they name must be in this list; creating a label is a +/// maintainer action that updates this list in the same pull request. +/// +internal static class RepositoryLabels +{ + /// The known labels, case-sensitive. + public static readonly IReadOnlySet Known = new HashSet(StringComparer.Ordinal) + { + "bug", + "documentation", + "duplicate", + "enhancement", + "good first issue", + "help wanted", + "invalid", + "question", + "wontfix", + "abstractions", + "binding", + "fluent-api", + "aob-scanning", + "memory", + "processes", + "symbols", + "address-list", + "tests", + "ci", + "packaging", + "dependencies", + "github_actions" + }; +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Governance/ScorecardWorkflowTests.cs b/tests/CheatEngine.Client.Repository.Tests/Governance/ScorecardWorkflowTests.cs new file mode 100644 index 0000000..fe3b686 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Governance/ScorecardWorkflowTests.cs @@ -0,0 +1,90 @@ +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Governance; + +/// +/// Advisory OpenSSF Scorecard (PR-CQ-25, CI-BOTH-5). With publish_results: true the Scorecard API verifies the +/// workflow (ossf/scorecard-infra, api/app/server/verify_workflow.go) and rejects it when it has workflow- or job-level +/// env/defaults, workflow-level write permissions, id-token: write outside the Scorecard job, +/// containers or services, steps without uses, actions outside its allowlist, or an unsupported runner label. +/// These tests keep the file inside those limits (it deliberately breaks the repository's pwsh-defaults convention). +/// +public sealed class ScorecardWorkflowTests +{ + private const string WorkflowPath = ".github/workflows/scorecard.yml"; + + /// The verifier's action allowlist minus step-security/harden-runner (excluded by decision). + private static readonly HashSet ApprovedActions = new(StringComparer.Ordinal) + { + "actions/checkout", + "actions/create-github-app-token", + "actions/upload-artifact", + "github/codeql-action/upload-sarif", + "ossf/scorecard-action" + }; + + private static readonly YamlMappingNode Workflow = GovernanceFile.LoadYaml(WorkflowPath); + + [Fact] + public void ScorecardHasNoDefaultsOrEnvironmentAtAnyLevel() + { + Assert.False(GovernanceFile.Has(Workflow, "env")); + Assert.False(GovernanceFile.Has(Workflow, "defaults")); + foreach (YamlMappingNode job in GovernanceFile.Jobs(Workflow).Values) + { + foreach (string key in new[] { "env", "defaults", "container", "services" }) + { + Assert.False(GovernanceFile.Has(job, key), $"The Scorecard verifier rejects a job-level '{key}'."); + } + } + } + + [Fact] + public void ScorecardStepsOnlyUseApprovedActions() + { + YamlMappingNode job = Assert.Single(GovernanceFile.Jobs(Workflow)).Value; + IReadOnlyList steps = GovernanceFile.Steps(job); + + Assert.NotEmpty(steps); + Assert.All(steps, step => + { + Assert.False(GovernanceFile.Has(step, "run"), "The Scorecard verifier rejects run: steps."); + Assert.Contains(GovernanceFile.ActionName(step) ?? string.Empty, ApprovedActions); + }); + YamlMappingNode scorecard = GovernanceFile.StepUsing(steps, "ossf/scorecard-action"); + Assert.Equal("true", GovernanceFile.With(scorecard, "publish_results")); + } + + [Fact] + public void ScorecardRunsOnOneSupportedUbuntuLabel() + { + YamlMappingNode job = Assert.Single(GovernanceFile.Jobs(Workflow)).Value; + string label = GovernanceFile.Scalar(job, "runs-on") ?? string.Empty; + + Regex verifierLabel = new(@"^ubuntu-(latest|\d{2}\.\d{2})(-arm)?$", RegexOptions.None, TimeSpan.FromSeconds(1)); + + Assert.Matches(verifierLabel, label); + Assert.Equal("ubuntu-24.04", label); + } + + [Fact] + public void OnlyTheScorecardJobRequestsAnIdToken() + { + KeyValuePair job = Assert.Single(GovernanceFile.Jobs(Workflow)); + + Assert.Equal("analysis", job.Key); + Assert.Equal("write", GovernanceFile.Permissions(job.Value)["id-token"]); + Assert.False(GovernanceFile.Permissions(Workflow).ContainsKey("id-token")); + } + + [Fact] + public void ScorecardHasNoWorkflowLevelWritePermission() + { + IReadOnlyDictionary permissions = GovernanceFile.Permissions(Workflow); + + Assert.NotEmpty(permissions); + Assert.All(permissions.Values, level => Assert.Equal("read", level)); + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Infrastructure/RepositoryRoot.cs b/tests/CheatEngine.Client.Repository.Tests/Infrastructure/RepositoryRoot.cs new file mode 100644 index 0000000..f1cf493 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Infrastructure/RepositoryRoot.cs @@ -0,0 +1,66 @@ +namespace CheatEngine.Client.Repository.Tests.Infrastructure; + +/// Locates the repository from the test output directory. +internal static class RepositoryRoot +{ + private const string SolutionFileName = "CheatEngine.Client.slnx"; + + /// The directory that contains CheatEngine.Client.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.Client.Repository.Tests/LockFiles/LockFileTests.cs b/tests/CheatEngine.Client.Repository.Tests/LockFiles/LockFileTests.cs new file mode 100644 index 0000000..3dfdc4c --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/LockFiles/LockFileTests.cs @@ -0,0 +1,291 @@ +using System.Text.Json.Nodes; + +using CheatEngine.Client.Repository.Tests.Infrastructure; +using CheatEngine.Client.Repository.Tests.Packaging; + +namespace CheatEngine.Client.Repository.Tests.LockFiles; + +/// +/// Structural checks of every committed lock file, so a broken one is reported before CI restores anything. Every +/// project restores with a committed lock file; the three Coexistence fixtures stay outside Central Package +/// Management with version 1 lock files (a solution-level --force-evaluate once gave them CentralTransitive +/// entries and broke every locked restore, which is why regeneration always restores each project on its own); the +/// whole graph consumes one CheatEngine.SDK identity, the pin of eng/CheatEngineSdk.props (ADR-10: the Client +/// follows the consumed package); every lock file ends exactly as NuGet writes it. +/// +public sealed class LockFileTests +{ + private const string LockFileName = "packages.lock.json"; + private const string TemplateContentProject = + "templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj"; + private const string CoexistenceFolder = "tests/CheatEngine.Client.LivePlugin.Coexistence/"; + private const string CoexistenceProps = CoexistenceFolder + "CoexistencePlugin.props"; + + // The reviewed NuGet content hash of the pinned CheatEngine.SDK package (SHA-512 of the unsigned package, base64), + // as every lock file records it. The version it belongs to is the pin itself (SdkPin.Version). + private const string ConsumedSdkContentHash = + "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ=="; + + [Fact] + public void EveryProjectHasACommittedLockFileExceptTheTemplateContent() + { + List missing = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + string lockFile = LockFileOf(project); + bool exists = File.Exists(Path.Combine(RepositoryRoot.Path, lockFile)); + if (project == TemplateContentProject) + { + Assert.False(exists, + $"{lockFile} records one machine's restore of the template content, which each plugin restores " + + "for itself; delete it."); + continue; + } + + if (!exists) + { + missing.Add(lockFile); + } + } + + Assert.True(missing.Count == 0, + $"Missing lock files (regenerate them with 'dotnet restore --force-evaluate' and commit them): {string.Join(", ", missing)}"); + } + + [Fact] + public void LockFilesParseAndDeclareASupportedFormatVersion() + { + HashSet projectFolders = new(StringComparer.Ordinal); + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + projectFolders.Add(FolderOf(project)); + } + + List lockFiles = [.. RepositoryRoot.EnumerateSourceFiles(LockFileName)]; + Assert.NotEmpty(lockFiles); + foreach (string lockFile in lockFiles) + { + Assert.True(projectFolders.Contains(FolderOf(lockFile)), + $"{lockFile} has no project next to it; delete the orphan."); + JsonObject root = ReadLock(lockFile); + int version = root["version"]?.GetValue() ?? 0; + Assert.True(version is 1 or 2, $"{lockFile} declares unsupported lock format version {version}."); + Assert.True(root["dependencies"] is JsonObject, $"{lockFile} has no dependencies object."); + } + } + + [Fact] + public void CoexistenceFixturesKeepVersion1LockFilesWithoutCentralTransitiveEntries() + { + XDocument props = XDocument.Load(Path.Combine(RepositoryRoot.Path, CoexistenceProps)); + XElement centralManagement = Assert.Single(props.Descendants("ManagePackageVersionsCentrally")); + Assert.Equal("false", centralManagement.Value.Trim()); + + string[] fixtures = CoexistenceFixtures(); + Assert.Equal(3, fixtures.Length); + foreach (string fixture in fixtures) + { + string projectText = File.ReadAllText(Path.Combine(RepositoryRoot.Path, fixture)); + Assert.Contains("CoexistencePlugin.props", projectText, StringComparison.Ordinal); + + string lockFile = LockFileOf(fixture); + JsonObject root = ReadLock(lockFile); + Assert.True(root["version"]?.GetValue() == 1, + $"{lockFile} must stay a version 1 lock file: the fixture is outside Central Package Management."); + foreach ((string section, string id, JsonObject entry) in Dependencies(root)) + { + Assert.False(TypeOf(entry) == "CentralTransitive", + $"{lockFile} [{section}] {id} is CentralTransitive: a solution-level --force-evaluate rewrote it. " + + "Regenerate by restoring each Coexistence fixture on its own with --force-evaluate (never the solution, which broke this once)."); + } + } + } + + [Fact] + public void CentralPackageManagementProjectsHaveVersion2LockFiles() + { + HashSet fixtures = new(CoexistenceFixtures(), StringComparer.Ordinal); + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (project == TemplateContentProject || fixtures.Contains(project)) + { + continue; + } + + string lockFile = LockFileOf(project); + JsonObject root = ReadLock(lockFile); + Assert.True(root["version"]?.GetValue() == 2, + $"{lockFile} must be a version 2 lock file: {project} uses Central Package Management."); + } + + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.*proj") + .Concat(RepositoryRoot.EnumerateSourceFiles("*.props"))) + { + if (file == TemplateContentProject || file.StartsWith(CoexistenceFolder, StringComparison.Ordinal)) + { + continue; + } + + XDocument document = XDocument.Load(Path.Combine(RepositoryRoot.Path, file)); + foreach (XElement element in document.Descendants("ManagePackageVersionsCentrally")) + { + Assert.False(string.Equals(element.Value.Trim(), "false", StringComparison.OrdinalIgnoreCase), + $"{file} turns Central Package Management off; only the Coexistence fixtures may."); + } + } + } + + [Fact] + public void EveryLockResolvesThePinnedSdkWithTheRecordedContentHash() + { + string pin = SdkPin.Version; + List consumers = []; + foreach (string lockFile in RepositoryRoot.EnumerateSourceFiles(LockFileName)) + { + foreach ((string section, string id, JsonObject entry) in Dependencies(ReadLock(lockFile))) + { + if (!string.Equals(id, "CheatEngine.SDK", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + consumers.Add(lockFile); + Assert.True((string?) entry["resolved"] == pin, + $"{lockFile} [{section}] resolves CheatEngine.SDK {(string?) entry["resolved"]}; " + + $"the Client consumes the pin {pin} ({SdkPin.PropsPath})."); + Assert.True((string?) entry["contentHash"] == ConsumedSdkContentHash, + $"{lockFile} [{section}] records CheatEngine.SDK contentHash {(string?) entry["contentHash"]}, " + + $"not the reviewed hash of the published {pin} package."); + } + } + + Assert.Contains("libs/CheatEngine.Client.Core/packages.lock.json", consumers); + } + + [Fact] + public void NoLockFileResolvesAClientPackageFromNuGet() + { + foreach (string lockFile in RepositoryRoot.EnumerateSourceFiles(LockFileName)) + { + foreach ((string section, string id, JsonObject entry) in Dependencies(ReadLock(lockFile))) + { + if (id.StartsWith("CheatEngine.Client", StringComparison.OrdinalIgnoreCase)) + { + Assert.True(TypeOf(entry) == "Project", + $"{lockFile} [{section}] resolves {id} as '{TypeOf(entry)}' from a feed; Client projects are project references."); + } + } + } + } + + [Fact] + public void NativeAotProbeLockRecordsTheWinX64IlCompilerPackages() + { + List aotProjects = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + XDocument document = XDocument.Load(Path.Combine(RepositoryRoot.Path, project)); + bool publishAot = document.Descendants("PublishAot") + .Any(static element => string.Equals(element.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase)); + if (!publishAot) + { + continue; + } + + aotProjects.Add(project); + string? runtimeIdentifier = document.Descendants("RuntimeIdentifier").Select(static element => element.Value.Trim()) + .SingleOrDefault(); + Assert.False(string.IsNullOrEmpty(runtimeIdentifier), + $"{project} publishes Native AOT without a RuntimeIdentifier, so its lock file cannot record the ILCompiler runtime pack."); + + string lockFile = LockFileOf(project); + JsonObject dependencies = (JsonObject) ReadLock(lockFile)["dependencies"]!; + string runtimePack = $"runtime.{runtimeIdentifier}.Microsoft.DotNet.ILCompiler"; + bool recorded = dependencies.Any(section => + section.Key.EndsWith("/" + runtimeIdentifier, StringComparison.Ordinal) && + section.Value is JsonObject packages && + packages.ContainsKey(runtimePack)); + Assert.True(recorded, + $"{lockFile} has no '/{runtimeIdentifier}' section with {runtimePack}; 'dotnet publish --no-restore' would fail."); + } + + Assert.Contains("tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj", aotProjects); + } + + [Fact] + public void LockFilesEndExactlyAsNuGetWritesThem() + { + List lockFiles = [.. RepositoryRoot.EnumerateSourceFiles(LockFileName)]; + Assert.NotEmpty(lockFiles); + List offenders = []; + foreach (string lockFile in lockFiles) + { + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, lockFile)); + if (text.EndsWith('\n') || text.EndsWith('\r')) + { + offenders.Add(lockFile); + } + } + + Assert.True(offenders.Count == 0, + "NuGet writes no final newline, so an editor or a hand edit added one to: " + string.Join(", ", offenders) + + ". Regenerate each with 'dotnet restore --force-evaluate' instead of editing it."); + } + + private static string[] CoexistenceFixtures() + { + return RepositoryRoot.EnumerateSourceFiles("*.csproj") + .Where(static project => project.StartsWith(CoexistenceFolder, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + private static string FolderOf(string relativePath) + { + int separator = relativePath.LastIndexOf('/'); + return separator < 0 ? string.Empty : relativePath[..separator]; + } + + private static string LockFileOf(string project) + { + string folder = FolderOf(project); + return folder.Length == 0 ? LockFileName : $"{folder}/{LockFileName}"; + } + + private static JsonObject ReadLock(string lockFile) + { + string path = Path.Combine(RepositoryRoot.Path, lockFile); + Assert.True(File.Exists(path), $"{lockFile} does not exist; run 'dotnet restore --force-evaluate' and commit it."); + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject + ?? throw new InvalidOperationException($"{lockFile} is not a JSON object."); + } + + private static IEnumerable<(string Section, string Id, JsonObject Entry)> Dependencies(JsonObject root) + { + if (root["dependencies"] is not JsonObject sections) + { + yield break; + } + + foreach (KeyValuePair section in sections) + { + if (section.Value is not JsonObject packages) + { + continue; + } + + foreach (KeyValuePair package in packages) + { + if (package.Value is JsonObject entry) + { + yield return (section.Key, package.Key, entry); + } + } + } + } + + private static string? TypeOf(JsonObject entry) + { + return (string?) entry["type"]; + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Packaging/ConsumerDiagnosticCatalogTests.cs b/tests/CheatEngine.Client.Repository.Tests/Packaging/ConsumerDiagnosticCatalogTests.cs new file mode 100644 index 0000000..63d653f --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Packaging/ConsumerDiagnosticCatalogTests.cs @@ -0,0 +1,236 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Packaging; + +/// +/// The CECLIENT build diagnostics that the Hosting package brings to a plugin project (its buildTransitive +/// targets) are catalogued: the codes those targets emit are exactly the rows of the "Build diagnostics" table of +/// the Hosting README, each row has its anchor once and states the severity of its emissions, and every emission +/// carries a help link to that anchor. +/// +/// +/// Source scans only. The targets emit a diagnostic in two ways: an MSBuild Error or Warning element +/// whose HelpLink is $(_CheatEngineClientHelpLink) followed by its code, and a Log.LogError +/// or Log.LogWarning call of an inline task, which receives that property as its HelpLink parameter +/// and appends its code. +/// +public sealed partial class ConsumerDiagnosticCatalogTests +{ + private const string TargetsPath = + "libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets"; + + private const string ReadmePath = "libs/CheatEngine.Client.Hosting/README.md"; + private const string TableHeading = "## Build diagnostics"; + private const string HelpLinkProperty = "_CheatEngineClientHelpLink"; + private const string HelpLinkReference = "$(" + HelpLinkProperty + ")"; + private const string ErrorKind = "Error"; + private const string WarningKind = "Warning"; + + /// + /// The start of the Severity cell of a code that the targets emit both as an error and as a warning: an error, + /// demoted to a warning by the property the rest of the cell names. + /// + private const string DemotableErrorSeverity = ErrorKind + "; " + WarningKind + " with "; + + private const string ExpectedHelpLink = + "https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Hosting/README.md#"; + + private const int RegexTimeoutMilliseconds = 1000; + + [Fact] + public void TheEmittedCodesAreExactlyTheRowsOfTheReadmeTable() + { + string[] emitted = + [.. EmittedCodes().Select(static emission => emission.Code).Distinct().Order(StringComparer.Ordinal)]; + string[] documented = [.. DocumentedCodes().Order(StringComparer.Ordinal)]; + string[] contiguous = [.. Enumerable.Range(1, emitted.Length).Select(static number => $"CECLIENT{number:000}")]; + + Assert.True(emitted.Length > 0, $"{TargetsPath} emits no CECLIENT diagnostic; the test would pass vacuously."); + Assert.Equal(documented.Distinct(StringComparer.Ordinal), documented); + Assert.Equal(emitted, documented); + Assert.Equal(contiguous, emitted); + } + + [Fact] + public void EveryRowHasItsAnchorOnce() + { + string readme = Read(ReadmePath); + foreach (string code in DocumentedCodes()) + { + int anchors = readme.Split($"", StringSplitOptions.None).Length - 1; + Assert.True(anchors == 1, $"{ReadmePath} must contain the anchor of {code} exactly once, found {anchors}."); + } + } + + [Fact] + public void EveryRowStatesTheSeverityOfItsEmissions() + { + ILookup kinds = EmittedCodes().ToLookup(static emission => emission.Code, + static emission => emission.Kind, StringComparer.Ordinal); + List rows = DocumentedRows(); + List offenders = []; + foreach (DocumentedRow row in rows) + { + string[] emitted = [.. kinds[row.Code].Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)]; + bool matches = emitted switch + { + [ErrorKind, WarningKind] => row.Severity.StartsWith(DemotableErrorSeverity, StringComparison.Ordinal), + [string kind] => string.Equals(row.Severity, kind, StringComparison.Ordinal), + _ => false + }; + if (!matches) + { + offenders.Add($"{row.Code}: the table says '{row.Severity}', the targets emit it as " + + $"[{string.Join(", ", emitted)}]"); + } + } + + Assert.True(rows.Count > 0, $"{ReadmePath} lists no diagnostic; the test would pass vacuously."); + Assert.True(offenders.Count == 0, + $"The Severity column must be '{ErrorKind}' or '{WarningKind}' as the targets emit the code, or " + + $"start with '{DemotableErrorSeverity}' for a code emitted as both:{Environment.NewLine}" + + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void EveryEmissionLinksToItsRow() + { + List offenders = []; + foreach (Emission emission in EmittedCodes()) + { + string expected = emission.FromTask ? $"HelpLink + \"{emission.Code}\"" : HelpLinkReference + emission.Code; + if (!string.Equals(emission.HelpLink, expected, StringComparison.Ordinal)) + { + offenders.Add($"{emission.Code}: HelpLink is '{emission.HelpLink ?? ""}', expected '{expected}'"); + } + } + + Assert.True(offenders.Count == 0, + $"Every CECLIENT diagnostic of {TargetsPath} links to its README row:{Environment.NewLine}" + + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void TheHelpLinkPropertyNamesTheHostingReadme() + { + XDocument targets = XDocument.Parse(Read(TargetsPath)); + string value = string.Empty; + foreach (XElement assignment in targets.Descendants(HelpLinkProperty)) + { + Assert.Null(assignment.Attribute("Condition")); + value = assignment.Value.Replace(HelpLinkReference, value, StringComparison.Ordinal); + } + + Assert.Equal(ExpectedHelpLink, value); + } + + [Fact] + public void EveryInlineTaskThatLogsADiagnosticReceivesTheHelpLink() + { + XDocument targets = XDocument.Parse(Read(TargetsPath)); + int tasks = 0; + foreach (XElement usingTask in targets.Descendants("UsingTask")) + { + string code = usingTask.Descendants("Code").Single().Value; + if (!LoggedDiagnostic().IsMatch(code)) + { + continue; + } + + tasks++; + string name = (string) usingTask.Attribute("TaskName")!; + XElement parameter = Assert.Single(usingTask.Descendants("ParameterGroup").Elements("HelpLink")); + Assert.Equal("true", (string?) parameter.Attribute("Required")); + XElement invocation = Assert.Single(targets.Descendants(name)); + Assert.Equal(HelpLinkReference, (string?) invocation.Attribute("HelpLink")); + } + + Assert.True(tasks > 0, $"{TargetsPath} has no inline task that logs a CECLIENT diagnostic."); + } + + /// Every CECLIENT emission of the targets, with its kind and the help link it passes. + private static List EmittedCodes() + { + XDocument targets = XDocument.Parse(Read(TargetsPath)); + List emissions = []; + foreach (XElement element in targets.Descendants() + .Where(static element => element.Name.LocalName is ErrorKind or WarningKind)) + { + string? code = (string?) element.Attribute("Code"); + if (code is not null && code.StartsWith("CECLIENT", StringComparison.Ordinal)) + { + emissions.Add(new Emission(code, element.Name.LocalName, (string?) element.Attribute("HelpLink"), + FromTask: false)); + } + } + + foreach (XElement code in targets.Descendants("Code")) + { + MatchCollection calls = LoggedDiagnostic().Matches(code.Value); + // A recognized call quotes its code twice, as the code and in the link: any other quoted code is an + // emission that this scan would miss. + Assert.Equal(2 * calls.Count, QuotedCode().Count(code.Value)); + foreach (Match call in calls) + { + emissions.Add(new Emission(call.Groups["code"].Value, call.Groups["kind"].Value, + call.Groups["link"].Value.Trim(), FromTask: true)); + } + } + + return emissions; + } + + /// The codes of the rows of the Hosting README's "Build diagnostics" table. + private static IEnumerable DocumentedCodes() + { + return DocumentedRows().Select(static row => row.Code); + } + + /// The rows of the Hosting README's "Build diagnostics" table: code and Severity cell. + private static List DocumentedRows() + { + string[] lines = Read(ReadmePath).ReplaceLineEndings("\n").Split('\n'); + int start = Array.IndexOf(lines, TableHeading); + Assert.True(start >= 0, $"{ReadmePath} has no '{TableHeading}' section."); + List rows = []; + for (int index = start + 1; + index < lines.Length && !lines[index].StartsWith("## ", StringComparison.Ordinal); + index++) + { + Match row = DiagnosticRow().Match(lines[index]); + if (row.Success) + { + Assert.Equal(row.Groups["anchor"].Value, row.Groups["code"].Value); + rows.Add(new DocumentedRow(row.Groups["code"].Value, row.Groups["severity"].Value.Trim())); + } + } + + return rows; + } + + private static string Read(string relativePath) + { + return File.ReadAllText(Path.Combine(RepositoryRoot.Path, relativePath)); + } + + /// A Log.LogError or Log.LogWarning call that reports a CECLIENT code. + [GeneratedRegex( + @"Log\.Log(?Error|Warning)\(\s*null,\s*""(?CECLIENT\d{3})"",\s*null,\s*(?[^,]+),", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex LoggedDiagnostic(); + + [GeneratedRegex(@"""CECLIENT\d{3}""", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex QuotedCode(); + + [GeneratedRegex( + @"^\|\s*CECLIENT\d{3})"">`(?CECLIENT\d{3})`\s*\|(?[^|]*)\|", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex DiagnosticRow(); + + /// One CECLIENT emission: its code, its kind (Error or Warning) and the help link it passes. + private sealed record Emission(string Code, string Kind, string? HelpLink, bool FromTask); + + private sealed record DocumentedRow(string Code, string Severity); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Packaging/NoStaleSdkWordingTests.cs b/tests/CheatEngine.Client.Repository.Tests/Packaging/NoStaleSdkWordingTests.cs new file mode 100644 index 0000000..05d191b --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Packaging/NoStaleSdkWordingTests.cs @@ -0,0 +1,138 @@ +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Packaging; + +/// +/// The shipped sources and the template never describe a CheatEngine.SDK the Client no longer consumes, nor a move to +/// the one it already consumes: no "SDK 1.0.0", no "SDK 2.0" owners still to come, no migration guide, and no promise +/// of what happens "when the Client migrates" or "until the SDK 2.0". The phrases are found across wrapped lines and +/// comment markers, in every text file of libs/, src/, source-generators/ and templates/. +/// +public sealed partial class NoStaleSdkWordingTests +{ + private const int RegexTimeoutMilliseconds = 1000; + + private static readonly (string Phrase, Regex Pattern)[] StalePhrases = + [ + ("SDK 1.0.0", RetiredSdkVersion()), + ("SDK 2.0 ... owners", FutureSdkOwners()), + ("migration guide", MigrationGuide()), + ("when the Client migrates", WhenTheClientMigrates()), + ("until the SDK 2.0", UntilTheSdk()) + ]; + + [Fact] + public void ShippedSourcesAndTheTemplateUseNoStaleSdkWording() + { + List offenders = []; + int scanned = 0; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*").Order(StringComparer.Ordinal)) + { + if (!SdkPinTests.IsShippedSource(file) || file.EndsWith("/packages.lock.json", StringComparison.Ordinal) || + !SdkPinTests.IsTextFile(file)) + { + continue; + } + + scanned++; + string[] lines = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, file)); + offenders.AddRange(FindStaleWording(lines).Select(finding => $"{file}:{finding.Line} → {finding.Phrase}")); + } + + Assert.True(scanned >= 100, $"Expected the shipped sources, READMEs and template files, found {scanned}."); + Assert.True(offenders.Count == 0, + "State what the consumed CheatEngine.SDK (eng/CheatEngineSdk.props) does today; a wording about another " + + "SDK version or a pending migration is stale:" + Environment.NewLine + + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void TheDetectorFindsEveryStaleFormAcrossWrappedCommentsAndSparesTheCurrentWording() + { + string[] stale = + [ + "/// Consumes CheatEngine.SDK", + "/// 1.0.0 through a boolean port.", + "\t// Unavailable until the SDK 2.0 hotkey and timer", + "\t// owners are adopted.", + "See the Migration Guide.", + "" + ]; + string[] current = + [ + "/// CheatEngine.SDK 2.0.0 Owned<T>.ReleaseWithOutcome always consumes the owner.", + "Neighbours built on CheatEngine.SDK 1.x keep their own bridge; owners stay theirs.", + "CheatEngine.SDK 2.0.0's `LuaOptional` lets a binding omit a trailing argument.", + "Moving to another major is a deliberate migration, not a dependency bump." + ]; + + Assert.Equal( + [ + (1, "SDK 1.0.0"), + (3, "SDK 2.0 ... owners"), + (3, "until the SDK 2.0"), + (5, "migration guide"), + (6, "when the Client migrates") + ], FindStaleWording(stale)); + Assert.Empty(FindStaleWording(current)); + } + + /// + /// The 1-based line on which each stale phrase starts. Leading whitespace and comment markers are removed and the + /// lines are joined with one space, so a phrase that a comment wraps over two lines is still found. + /// + private static List<(int Line, string Phrase)> FindStaleWording(string[] lines) + { + StringBuilder text = new(); + List lineStarts = []; + foreach (string line in lines) + { + lineStarts.Add(text.Length); + text.Append(CommentPrefix().Replace(line, string.Empty).TrimEnd()).Append(' '); + } + + string flat = text.ToString(); + List<(int Line, string Phrase)> findings = []; + foreach ((string phrase, Regex pattern) in StalePhrases) + { + foreach (Match match in pattern.Matches(flat)) + { + int line = lineStarts.BinarySearch(match.Index); + findings.Add((line >= 0 ? line + 1 : ~line, phrase)); + } + } + + return + [ + .. findings.OrderBy(static finding => finding.Line) + .ThenBy(static finding => finding.Phrase, StringComparer.Ordinal) + ]; + } + + /// Leading whitespace and a C#, XML documentation, block, shell or HTML comment marker. + [GeneratedRegex(@"^\s*(?:///|//|/\*+|\*/|\*|\#|"; + private const string TableEnd = ""; + private const int RegexTimeoutMilliseconds = 1000; + + private static readonly string[] ClaimDocumentNames = ["README.md", "CHANGELOG.md", "RELEASING.md"]; + + [Fact] + public void NoQualificationClaimWithoutCommittedEvidence() + { + if (HasCommittedEvidence()) + { + return; + } + + List claims = []; + foreach (string document in ClaimDocuments()) + { + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, document)); + claims.AddRange(FindClaims(document, text).Select(claim => $"{document}:{claim.Line} → {claim.Kind}")); + } + + Assert.True(claims.Count == 0, + $"{EvidenceDirectory} holds no committed run summary ({SummarySchema}), yet these lines claim a host qualification. " + + "Record the evidence of a live run first, or say what is targeted instead:" + Environment.NewLine + + string.Join(Environment.NewLine, claims)); + } + + [Fact] + public void TheClaimDetectorRecognizesEveryClaimFormAndTheCurrentWording() + { + const string claimsText = """ + ## [1.0.0] + ### Qualification + - Q05 (C3, run 20260930T101530Z-a1b2, 2026-09-30): Passed + - Q30.b (C4): Waived until 2026-12-31 + The Client is qualified on Cheat Engine 7.7.0.10621 x64. + + | Capability id | Implementation | Qualification | + |---|---|---| + | `Client.TypedMemory` | Operational adapter | Satisfied (run 20260930T101530Z-a1b2) | + | `Client.Tables` | Operational adapter | Unknown until a Client receipt exists | + + """; + const string currentText = """ + ## Qualification gate + No Client capability is host-qualified yet: the qualification gate stays `Unknown` until a Client receipt exists. + These are package-level results (fixture level C2); a Cheat Engine host run of Q40 is a separate qualification. + A result on this profile authorizes no x86 or ARM64 plugin claim; S0 receipts are never committed. + + | Capability id | Implementation | Package | Host | Qualification | Status reported at runtime | + |---|---|---|---|---|---| + | `Client.TypedMemory` | Operational adapter | Evidence | Not probed | Unknown until a Client receipt exists | `Unknown` | + | `Client.UnsafeLuaExecution` | Operational, policy opt-in | Evidence | Not probed | Never qualified: no scenario | `Unknown` | + + """; + + Assert.Equal( + [ + (2, "a Qualification section"), + (3, "a scenario verdict"), + (3, "a live run id"), + (4, "a scenario verdict"), + (5, "a host qualification statement"), + (9, "a live run id"), + (9, "a qualified capability") + ], FindClaims("CHANGELOG.md", claimsText)); + Assert.Empty(FindClaims("CHANGELOG.md", currentText)); + Assert.DoesNotContain((2, "a Qualification section"), FindClaims("README.md", claimsText)); + } + + [Fact] + public void EveryShippingSourceIsBoundByTheDigest() + { + IReadOnlyList inputs = QualifiedSourceDigest.EnumerateInputs(RepositoryRoot.Path); + HashSet visible = new(RepositoryRoot.EnumerateSourceFiles("*").Where(static path => !path.StartsWith(".claude/", StringComparison.Ordinal)), + StringComparer.Ordinal); + string[] shipping = + [ + .. visible.Where(static path => QualifiedSourceDigest.IncludedDirectories.Any(directory => path.StartsWith(directory, StringComparison.Ordinal))) + .Where(static path => path.EndsWith(".cs", StringComparison.Ordinal) || path.EndsWith(".csproj", StringComparison.Ordinal) || + path.EndsWith(".props", StringComparison.Ordinal) || path.EndsWith(".targets", StringComparison.Ordinal) || + path.EndsWith("packages.lock.json", StringComparison.Ordinal)) + .Where(static path => !path.EndsWith("/HostQualificationEvidence.cs", StringComparison.Ordinal)) + ]; + + Assert.NotEmpty(shipping); + Assert.Empty(shipping.Except(inputs, StringComparer.Ordinal)); + Assert.Empty(inputs.Except(visible, StringComparer.Ordinal)); + Assert.Contains("global.json", inputs); + Assert.Contains("eng/CheatEngineSdk.props", inputs); + Assert.DoesNotContain(inputs, static path => path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || + Path.GetFileName(path).StartsWith("PublicAPI.", StringComparison.Ordinal)); + Assert.Equal(QualifiedSourceDigest.Compute(RepositoryRoot.Path), QualifiedSourceDigest.Compute(RepositoryRoot.Path)); + } + + /// Whether the evidence folder holds a run summary of the qualification schema. + private static bool HasCommittedEvidence() + { + string summary = Path.Combine(RepositoryRoot.Path, EvidenceDirectory, "summary.json"); + if (!File.Exists(summary)) + { + return false; + } + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(summary)); + return document.RootElement.TryGetProperty("schema", out JsonElement schema) && + string.Equals(schema.GetString(), SummarySchema, StringComparison.Ordinal); + } + + /// Every README, the CHANGELOG, RELEASING and every Markdown file that carries a capability table. + private static IEnumerable ClaimDocuments() + { + return RepositoryRoot.EnumerateSourceFiles("*.md") + .Where(static path => !path.StartsWith(".claude/", StringComparison.Ordinal)) + .Where(static path => ClaimDocumentNames.Contains(Path.GetFileName(path), StringComparer.Ordinal) || + File.ReadAllText(Path.Combine(RepositoryRoot.Path, path)).Contains(TableStart, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal); + } + + /// The 1-based line and kind of every qualification claim in a document. + private static List<(int Line, string Kind)> FindClaims(string path, string text) + { + List<(int Line, string Kind)> claims = []; + string[] lines = text.ReplaceLineEndings("\n").Split('\n'); + int qualificationColumn = -1; + bool inTable = false; + for (int index = 0; index < lines.Length; index++) + { + string line = lines[index]; + int number = index + 1; + if (string.Equals(path, "CHANGELOG.md", StringComparison.Ordinal) && QualificationSection().IsMatch(line)) + { + claims.Add((number, "a Qualification section")); + } + + if (ScenarioVerdict().IsMatch(line)) + { + claims.Add((number, "a scenario verdict")); + } + + if (RunId().IsMatch(line)) + { + claims.Add((number, "a live run id")); + } + + if (QualifiedOnHost().IsMatch(line)) + { + claims.Add((number, "a host qualification statement")); + } + + string trimmed = line.Trim(); + if (trimmed == TableStart) + { + inTable = true; + qualificationColumn = -1; + continue; + } + + if (trimmed == TableEnd) + { + inTable = false; + continue; + } + + if (!inTable || !trimmed.StartsWith('|')) + { + continue; + } + + string[] cells = [.. trimmed.Trim('|').Split('|').Select(static cell => cell.Trim())]; + if (qualificationColumn < 0) + { + qualificationColumn = Array.IndexOf(cells, "Qualification"); + continue; + } + + if (qualificationColumn < cells.Length && QualifiedCell().IsMatch(cells[qualificationColumn])) + { + claims.Add((number, "a qualified capability")); + } + } + + return claims; + } + + /// The CHANGELOG section a recorded run adds. + [GeneratedRegex(@"^#{2,4}\s+Qualification\s*$", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex QualificationSection(); + + /// A scenario id followed, on the same line, by a verdict. + [GeneratedRegex(@"\bQ\d{2}(?:\.[a-z])?\b.*\b(?:Passed|Waived)\b", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ScenarioVerdict(); + + /// The id of a live run (yyyyMMddTHHmmssZ-xxxx). + [GeneratedRegex(@"\b\d{8}T\d{6}Z-[0-9a-f]{4}\b", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex RunId(); + + /// "qualified on/against/with Cheat Engine". + [GeneratedRegex(@"\b(?:host-)?qualified\s+(?:on|against|with)\s+(?:Cheat\s*Engine|CE)\b", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, RegexTimeoutMilliseconds)] + private static partial Regex QualifiedOnHost(); + + /// A Qualification cell that reports a pass; a negated word ("never qualified", "not passed") is no claim. + [GeneratedRegex(@"(?` and ``, in any README) equal to the Client's capability + catalog: each `ClientCapabilityId` listed once, the catalog's operational adapter and experimental id in the + Implementation column, and exactly the live scenarios its qualification gate requires in the Qualification column. + In a table with a "1.0 status" column, a row starts with `Experimental` exactly when the catalog gives its + capability an experimental diagnostic id, and with `Available` otherwise + (`CapabilityTableStatusColumnsMarkExactlyTheExperimentalApis`). + `InstallGuidesStateTheQualifiedHostProfile` proves that the three install guides state the supported host profile, + the host executable and runtime configuration hashes and the NuGet content hash that Core's lock file records. +- `SourcePolicy/` holds the source rules no analyzer expresses: + - `AotProbeCoverageTests`: the Native AOT probe calls every public member of CheatEngine.Client.Fluent, read from + the Fluent PublicAPI baselines. Each public Fluent type has an `Exercise` method that `Run` calls, each member has + at least as many call sites there as public signatures (counted as text, comments excluded, so a review keeps one + call per overload), and the probe's entry point returns 1 when `AotProbeFluentCalls.Run()` fails; + - `ErrorTextClassificationPolicyTests`: Client libraries never classify a failure by Cheat Engine or Lua error text + (A07-22, A24-24), only by type and status; + - `InterfaceStabilityRemarkTests`: every public interface states whether it is Call-only or Implementable, and the + versioning sections of the root and `CheatEngine.Client` READMEs name exactly the Implementable ones; + - `SingleFileSuppressionTests`: IL3000 is suppressed exactly once, on the getter of + `CheatEnginePluginBuilder.PluginDirectory`, under ADR-02; + - `TemplateLoggingPolicyTests`: the template's log events carry no address, value or raw failure (Q46), checked from + the committed text because the template compiles only after `dotnet new`. +- `Qualification/QualificationEvidenceTests` keeps the evidence discipline of the live qualification: + `NoQualificationClaimWithoutCommittedEvidence` refuses, while + `tests/CheatEngine.Client.Tests/LiveQualification/Evidence/` holds no committed run summary, any README, CHANGELOG, + RELEASING or capability-table line that claims a host qualification (a CHANGELOG `Qualification` section, a scenario + id with a `Passed` or `Waived` verdict, a live run id, a sentence stating that something is qualified on the host, or + a Qualification cell that reports a pass). `TheClaimDetectorRecognizesEveryClaimFormAndTheCurrentWording` pins those forms, and + `EveryShippingSourceIsBoundByTheDigest` proves that the shipping source digest (`QualifiedSourceDigest`, compiled in + from the live runner of `CheatEngine.Client.Tests`) covers every shipping source file this project sees. +- `Governance/` holds the repository governance contracts that stay meaningful without a bespoke script or a required + check of their own (audit rows PR-CQ-08/17/23/25/37, A21-36): + - `CodeQlWorkflowTests`, `ScorecardWorkflowTests` and `OnlineZizmorWorkflowTests` prove the advisory security + workflows: a manual traced build of the whole shipped graph without dependency cache, the Scorecard verifier's + restrictions, the same zizmor version as the Gate and no SARIF upload from forks; + - `DependabotConfigurationTests` proves the cooldowns, the covered ecosystems, the ignores that protect frozen + decisions (CheatEngine.SDK majors, Roslyn, SDK-implicit packages) and that a CheatEngine.SDK update, version or + security, a reviewed pin move, never joins a grouped pull request (`CheatEngineSdkUpdatesAreNeverGrouped`); + - `CommunityHealthTests` and `IssueFormTests` prove `SECURITY.md`, `CODE_OF_CONDUCT.md`, CODEOWNERS and the issue + forms, that no form presents a profile as qualified, and that the version placeholders name a stable version of + the Client's major line, the pinned CheatEngine.SDK and its content hash + (`VersionPlaceholdersNameTheClientLineAndThePinnedSdk`). + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Repository.Tests\CheatEngine.Client.Repository.Tests.csproj +``` diff --git a/tests/CheatEngine.Client.Repository.Tests/Release/ReleaseWorkflowTests.cs b/tests/CheatEngine.Client.Repository.Tests/Release/ReleaseWorkflowTests.cs new file mode 100644 index 0000000..f023e4b --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Release/ReleaseWorkflowTests.cs @@ -0,0 +1,690 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; +using CheatEngine.Client.Repository.Tests.Packaging; +using CheatEngine.Client.Repository.Tests.Workflows; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Release; + +/// +/// The release workflow keeps the contract order (verify, ci, stage, attest, draft-release, publish, verify-publication, +/// finalize-release), publishes only from a tag of this repository through the nuget environment, and never caches +/// packages (audit A21-06). Pins, runners, timeouts and checkout credentials are checked for every workflow by the +/// workflow contract tests. +/// +public sealed partial class ReleaseWorkflowTests +{ + private const string WorkflowPath = ".github/workflows/release.yml"; + private const string ProvenancePredicate = "https://slsa.dev/provenance/v1"; + private const string SpdxPredicate = "https://spdx.dev/Document/v2.2"; + + private static readonly string[] JobOrder = ["verify", "ci", "stage", "attest", "draft-release", "publish", "verify-publication", "finalize-release"]; + + private static readonly Dictionary ExpectedNeeds = new(StringComparer.Ordinal) + { + ["verify"] = [], + ["ci"] = ["verify"], + ["stage"] = ["verify", "ci"], + ["attest"] = ["verify", "ci", "stage"], + ["draft-release"] = ["verify", "ci", "attest"], + ["publish"] = ["verify", "ci", "draft-release"], + ["verify-publication"] = ["verify", "publish"], + ["finalize-release"] = ["verify", "draft-release", "verify-publication"] + }; + + /// Every package after the Client packages it depends on. + private static readonly string[] PushOrder = + [ + "CheatEngine.Client.Abstractions", "CheatEngine.Client.Fluent", "CheatEngine.Client.Core", + "CheatEngine.Client.Extensions.DependencyInjection", "CheatEngine.Client.Hosting", "CheatEngine.Client", + "CheatEngine.Client.Templates" + ]; + + private static readonly Lazy Workflow = new(LoadWorkflow); + + [Fact] + public void ReleaseJobsFollowTheContractOrder() + { + YamlMappingNode jobs = Mapping(Workflow.Value, "jobs"); + YamlMappingNode on = Mapping(Workflow.Value, "on"); + + Assert.Equal(JobOrder, jobs.Children.Keys.Select(static key => ((YamlScalarNode) key).Value)); + foreach ((string job, string[] needs) in ExpectedNeeds) + { + Assert.Equal(needs, Needs(Mapping(jobs, job))); + } + + string[] triggers = ["push", "workflow_dispatch"]; + string[] tags = ["v*.*.*"]; + Assert.Equal(triggers, on.Children.Keys.Select(static key => ((YamlScalarNode) key).Value)); + Assert.Equal(tags, Sequence(Mapping(on, "push"), "tags")); + Assert.Equal("false", Scalar(Mapping(Workflow.Value, "concurrency"), "cancel-in-progress")); + Assert.Equal("${{ github.workflow }}-${{ github.ref }}", Scalar(Mapping(Workflow.Value, "concurrency"), "group")); + YamlMappingNode permissions = Mapping(Workflow.Value, "permissions"); + Assert.Equal("read", Text(Assert.Single(permissions.Children, static entry => ((YamlScalarNode) entry.Key).Value == "contents").Value)); + Assert.Single(permissions.Children); + } + + [Fact] + public void ReleaseCallsCiWithThePackageVersionAndNinetyDayRetentionButNoSonar() + { + YamlMappingNode ci = Mapping(Mapping(Workflow.Value, "jobs"), "ci"); + YamlMappingNode with = Mapping(ci, "with"); + + Assert.Equal("CI", Scalar(ci, "name")); + Assert.Equal("./.github/workflows/ci.yml", Scalar(ci, "uses")); + Assert.Equal("${{ needs.verify.outputs.version }}", Scalar(with, "package-version")); + Assert.Equal("90", Scalar(with, "package-retention-days")); + Assert.Equal(2, with.Children.Count); + Assert.False(ci.Children.ContainsKey(new YamlScalarNode("secrets")), "release.yml must not pass secrets to ci.yml (no Sonar on the release path)."); + } + + [Fact] + public void OnlyThePublishJobUsesTheNugetEnvironment() + { + YamlMappingNode jobs = Mapping(Workflow.Value, "jobs"); + foreach ((YamlNode key, YamlNode value) in jobs.Children) + { + string job = ((YamlScalarNode) key).Value!; + YamlMappingNode definition = (YamlMappingNode) value; + bool hasEnvironment = definition.Children.ContainsKey(new YamlScalarNode("environment")); + bool readsSecrets = JobText(job).Contains("secrets.", StringComparison.Ordinal); + bool requestsIdToken = definition.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions) + && ((YamlMappingNode) permissions).Children.TryGetValue(new YamlScalarNode("id-token"), out YamlNode? idToken) + && Text(idToken) == "write"; + + Assert.Equal(job == "publish", hasEnvironment); + Assert.Equal(job == "publish", readsSecrets); + Assert.Equal(job is "publish" or "attest", requestsIdToken); + } + + YamlMappingNode environment = Mapping(Mapping(jobs, "publish"), "environment"); + Assert.Equal("nuget", Scalar(environment, "name")); + } + + /// + /// A workflow_dispatch started from a tag has ref type 'tag' as well, so the event is part of every guard: attest, + /// draft-release and publish share one condition, verify receives the event name and writes empty outputs for + /// anything but a push, and no later job can run once they are skipped. + /// + [Fact] + public void DraftAndPublishRunOnlyForTagPushesOfThisRepository() + { + YamlMappingNode jobs = Mapping(Workflow.Value, "jobs"); + string[] clauses = + [ + "github.event_name == 'push'", "github.ref_type == 'tag'", + "github.repository == 'CheatEngineNet/CheatEngine.Client'", "needs.verify.outputs.version != ''" + ]; + string[] guardedJobs = ["attest", "draft-release", "publish"]; + string attest = Scalar(Mapping(jobs, "attest"), "if"); + + foreach (string job in guardedJobs) + { + string condition = Scalar(Mapping(jobs, job), "if"); + Assert.Equal(attest, condition); + foreach (string clause in clauses) + { + Assert.True(condition.Contains(clause, StringComparison.Ordinal), $"The '{job}' condition '{condition}' lacks {clause}."); + } + + Assert.DoesNotContain("||", condition, StringComparison.Ordinal); + } + + // Later jobs have no condition of their own that could run them after a skipped need. + List bypasses = []; + foreach ((YamlNode key, YamlNode value) in jobs.Children) + { + if (((YamlMappingNode) value).Children.TryGetValue(new YamlScalarNode("if"), out YamlNode? condition) + && StatusFunction().IsMatch(Text(condition))) + { + bypasses.Add($"{((YamlScalarNode) key).Value}: {Text(condition)}"); + } + } + + Assert.True(bypasses.Count == 0, $"No release job may run after a skipped or failed need:{Environment.NewLine}{string.Join(Environment.NewLine, bypasses)}"); + string[] publicationNeeds = ["verify", "publish"]; + Assert.Equal(publicationNeeds, Needs(Mapping(jobs, "verify-publication"))); + Assert.Contains("verify-publication", Needs(Mapping(jobs, "finalize-release"))); + + YamlMappingNode verifyTag = Step(Mapping(jobs, "verify"), "tag"); + Assert.Equal("${{ github.event_name }}", Scalar(Mapping(verifyTag, "env"), "EVENT_NAME")); + Assert.Contains("$env:EVENT_NAME -ne 'push'", Scalar(verifyTag, "run"), StringComparison.Ordinal); + + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, WorkflowPath)); + Assert.DoesNotContain("gh release upload", text, StringComparison.Ordinal); + Assert.DoesNotContain(Mapping(Workflow.Value, "on").Children.Keys, static key => ((YamlScalarNode) key).Value!.StartsWith("pull_request", StringComparison.Ordinal)); + } + + /// + /// The contents: write token of draft-release and finalize-release reaches only the steps that call gh: never the + /// restore and test steps, which run repository MSBuild targets, NuGet package targets and test code. + /// + [Fact] + public void TheWriteTokenReachesOnlyTheStepsThatCallGitHub() + { + List offenders = []; + foreach ((YamlNode key, YamlNode value) in Mapping(Workflow.Value, "jobs").Children) + { + string job = ((YamlScalarNode) key).Value!; + YamlMappingNode definition = (YamlMappingNode) value; + if (definition.Children.TryGetValue(new YamlScalarNode("env"), out YamlNode? jobEnvironment) + && ((YamlMappingNode) jobEnvironment).Children.ContainsKey(new YamlScalarNode("GH_TOKEN"))) + { + offenders.Add($"{job}: GH_TOKEN is set for the whole job"); + } + + if (!definition.Children.TryGetValue(new YamlScalarNode("steps"), out YamlNode? steps)) + { + continue; + } + + foreach (YamlMappingNode step in ((YamlSequenceNode) steps).Children.Cast()) + { + string name = step.Children.TryGetValue(new YamlScalarNode("name"), out YamlNode? nameNode) ? Text(nameNode) : "(unnamed)"; + bool hasToken = step.Children.TryGetValue(new YamlScalarNode("env"), out YamlNode? stepEnvironment) + && ((YamlMappingNode) stepEnvironment).Children.ContainsKey(new YamlScalarNode("GH_TOKEN")); + string run = step.Children.TryGetValue(new YamlScalarNode("run"), out YamlNode? runNode) ? Text(runNode) : string.Empty; + if (hasToken && (run.Length == 0 || run.Contains("dotnet ", StringComparison.Ordinal))) + { + offenders.Add($"{job} / {name}: GH_TOKEN reaches a step that is not a gh call"); + } + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void NoReleaseJobCachesPackages() + { + List offenders = []; + foreach ((YamlNode key, YamlNode value) in Mapping(Workflow.Value, "jobs").Children) + { + string job = ((YamlScalarNode) key).Value!; + if (!((YamlMappingNode) value).Children.TryGetValue(new YamlScalarNode("steps"), out YamlNode? steps)) + { + continue; + } + + foreach (YamlMappingNode step in ((YamlSequenceNode) steps).Children.Cast()) + { + string uses = step.Children.TryGetValue(new YamlScalarNode("uses"), out YamlNode? usesNode) ? Text(usesNode) : string.Empty; + YamlMappingNode? with = step.Children.TryGetValue(new YamlScalarNode("with"), out YamlNode? withNode) ? (YamlMappingNode) withNode : null; + string? cache = with is not null && with.Children.TryGetValue(new YamlScalarNode("cache"), out YamlNode? cacheNode) ? Text(cacheNode) : null; + if (uses.StartsWith("actions/cache", StringComparison.Ordinal)) + { + offenders.Add($"{job}: uses {uses}"); + } + + if (uses == "./.github/actions/setup-dotnet" && cache != "false") + { + offenders.Add($"{job}: the .NET setup must pass cache: 'false' (found '{cache}')"); + } + + if (uses.StartsWith("actions/setup-dotnet", StringComparison.Ordinal)) + { + offenders.Add($"{job}: uses actions/setup-dotnet directly instead of the repository setup action"); + } + } + } + + Assert.True(offenders.Count == 0, + $"No release job may restore from or save to a NuGet cache (cache poisoning of the published packages):{Environment.NewLine}{string.Join(Environment.NewLine, offenders)}"); + } + + [Fact] + public void PublishPushesTheSevenPackagesInDependencyOrder() + { + YamlSequenceNode steps = (YamlSequenceNode) Mapping(Mapping(Workflow.Value, "jobs"), "publish").Children[new YamlScalarNode("steps")]; + List stepList = steps.Children.Cast().ToList(); + int login = stepList.FindIndex(static step => step.Children.TryGetValue(new YamlScalarNode("uses"), out YamlNode? uses) + && Text(uses).StartsWith("NuGet/login@", StringComparison.Ordinal)); + int push = stepList.FindIndex(static step => step.Children.TryGetValue(new YamlScalarNode("run"), out YamlNode? run) + && Text(run).Contains("dotnet nuget push", StringComparison.Ordinal)); + Assert.True(login >= 0 && push > login, "The publish job must log in with NuGet/login right before the push step."); + Assert.Equal("${{ secrets.NUGET_USER }}", Scalar(Mapping(stepList[login], "with"), "user")); + + string script = Text(stepList[push].Children[new YamlScalarNode("run")]); + Assert.Equal(PushOrder, PackageIds()); + Assert.Contains("foreach ($id in @($env:PACKAGE_IDS -split", script, StringComparison.Ordinal); + Assert.Contains("--skip-duplicate", script, StringComparison.Ordinal); + Assert.Contains("https://api.nuget.org/v3/index.json", script, StringComparison.Ordinal); + Assert.Contains("--no-symbols", script, StringComparison.Ordinal); + Assert.Contains("$LASTEXITCODE", script, StringComparison.Ordinal); + } + + /// + /// attest writes SHA256SUMS next to the bundles, and publish checks every .nupkg and .snupkg against it before the + /// NuGet login, then pushes the seven packages without their symbols and only then the symbol packages, so a failed + /// symbol push never leaves a package unpublished and a re-run of the job completes it (PKG-08). + /// + [Fact] + public void PublishChecksEveryPackageAgainstSha256SumsBeforePushing() + { + List attest = Steps("attest"); + int sums = attest.FindIndex(static step => Run(step).Contains("SHA256SUMS", StringComparison.Ordinal) && Run(step).Contains("WriteAllText", StringComparison.Ordinal)); + int upload = attest.FindIndex(static step => Uses(step).StartsWith("actions/upload-artifact@", StringComparison.Ordinal) + && Scalar(Mapping(step, "with"), "name") == "attestation-bundles"); + Assert.True(sums >= 0 && upload > sums, "attest must write SHA256SUMS before it uploads the attestation-bundles artifact."); + Assert.DoesNotContain(Steps("draft-release"), static step => Run(step).Contains("WriteAllText", StringComparison.Ordinal)); + + List publish = Steps("publish"); + int download = publish.FindIndex(static step => Uses(step).StartsWith("actions/download-artifact@", StringComparison.Ordinal) + && Scalar(Mapping(step, "with"), "name") == "attestation-bundles"); + int check = publish.FindIndex(static step => Run(step).Contains("SHA256SUMS", StringComparison.Ordinal) && Run(step).Contains("Get-FileHash", StringComparison.Ordinal)); + int login = publish.FindIndex(static step => Uses(step).StartsWith("NuGet/login@", StringComparison.Ordinal)); + int packages = publish.FindIndex(static step => Run(step).Contains("dotnet nuget push", StringComparison.Ordinal)); + int symbols = publish.FindLastIndex(static step => Run(step).Contains("dotnet nuget push", StringComparison.Ordinal)); + Assert.True(download >= 0 && check > download && login > check && packages > login && symbols > packages, + "publish must download SHA256SUMS, check the packages against it, log in, push the packages, then push the symbol packages."); + + string checkScript = Run(publish[check]); + string[] required = [".nupkg", ".snupkg", "throw", "Get-ChildItem artifacts/nuget"]; + foreach (string value in required) + { + Assert.Contains(value, checkScript, StringComparison.Ordinal); + } + + Assert.Null(Yaml.Get(publish[check], "env")); + Assert.Contains("--no-symbols", Run(publish[packages]), StringComparison.Ordinal); + string symbolScript = Run(publish[symbols]); + Assert.Contains("*.snupkg", symbolScript, StringComparison.Ordinal); + Assert.Contains("--skip-duplicate", symbolScript, StringComparison.Ordinal); + Assert.DoesNotContain("--no-symbols", symbolScript, StringComparison.Ordinal); + } + + /// + /// The REST lookup of a release by tag returns published releases only, so finalize-release reads the draft with gh + /// release view, publishes it with gh release edit and then verifies what consumers download: every asset against + /// SHA256SUMS and, for an immutable release, the release attestation (PKG-06). + /// + [Fact] + public void FinalizeReadsTheReleaseWithGhReleaseViewAndPublishesWithGhReleaseEdit() + { + List steps = Steps("finalize-release"); + int state = steps.FindIndex(static step => StepId(step) == "state"); + int publish = steps.FindIndex(static step => Run(step).Contains("gh release edit", StringComparison.Ordinal)); + int verify = steps.FindIndex(static step => Run(step).Contains("gh release download", StringComparison.Ordinal)); + Assert.True(state >= 0 && publish > state && verify > publish, + "finalize-release must read the release state, then publish the draft, then verify the published release."); + + Assert.Matches(GhReleaseView(), Run(steps[state])); + Assert.Contains("draft=", Run(steps[state]), StringComparison.Ordinal); + Assert.Equal("steps.state.outputs.draft == 'true'", Scalar(steps[publish], "if")); + Assert.Matches(@"(?m)^\s*gh release edit \$env:TAG --draft=false\s*$", Run(steps[publish])); + + string verification = Run(steps[verify]); + Assert.Matches(GhReleaseView(), verification); + Assert.Matches(@"\bgh release download \$env:TAG --dir \$published\b", verification); + Assert.Matches(@"\bgh release verify \$env:TAG\s", verification); + Assert.Matches(@"\bgh release verify-asset \$env:TAG\s", verification); + string[] required = ["SHA256SUMS", "Get-FileHash", "$state.isImmutable", "$state.isDraft", "GITHUB_STEP_SUMMARY"]; + foreach (string value in required) + { + Assert.Contains(value, verification, StringComparison.Ordinal); + } + + Assert.DoesNotContain(steps, static step => GhApi().IsMatch(Run(step))); + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, WorkflowPath)); + Assert.DoesNotContain("releases/tags/", text, StringComparison.Ordinal); + } + + /// + /// Every gh attestation verify of the workflow pins the identity RELEASING.md gives consumers (this repository, the + /// release.yml signer workflow, the tag as source ref, GitHub-hosted runners) and names the predicate it checks; + /// finalize-release checks both the provenance and the SPDX SBOM of each published package. + /// + [Fact] + public void AttestationVerificationPinsSignerSourceRefAndHostedRunners() + { + string[] identity = + [ + "--repo", "CheatEngineNet/CheatEngine.Client", + "--signer-workflow", "CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml", + "--source-ref", "refs/tags/$env:TAG", + "--deny-self-hosted-runners" + ]; + string[] predicates = [ProvenancePredicate, SpdxPredicate]; + List offenders = []; + foreach (YamlNode key in Mapping(Workflow.Value, "jobs").Children.Keys) + { + string job = ((YamlScalarNode) key).Value!; + foreach (string script in Steps(job).Select(Run)) + { + string[] verifications = AttestationVerification().Matches(script).Select(static match => match.Value).ToArray(); + if (verifications.Length == 0) + { + continue; + } + + Match declaration = IdentityDeclaration().Match(script); + if (!declaration.Success || !Yaml.Tokens(declaration.Groups["body"].Value).SequenceEqual(identity)) + { + offenders.Add($"{job}: $identity is not {string.Join(' ', identity)}"); + } + + foreach (string verification in verifications) + { + // The tokenizer drops the splatting '@', so '@identity' reads as 'identity'. + List tokens = [.. Yaml.Tokens(verification)]; + int predicate = tokens.IndexOf("--predicate-type"); + bool pinned = tokens.Contains("identity") && predicate >= 0 && predicate < tokens.Count - 1 && predicates.Contains(tokens[predicate + 1]); + if (!pinned) + { + offenders.Add($"{job}: '{verification.Trim()}' must pass @identity and --predicate-type {string.Join(" or ", predicates)}"); + } + } + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + string finalize = Run(Steps("finalize-release").Single(static step => Run(step).Contains("gh release download", StringComparison.Ordinal))); + foreach (string predicate in predicates) + { + Assert.Contains($"@identity --predicate-type '{predicate}'", finalize, StringComparison.Ordinal); + } + + // attest checks its own attestations, against the bundles and in the repository, before anything is public, and + // the provenance covers the symbol packages too. + List attest = Steps("attest"); + int verifyStep = attest.FindIndex(static step => Run(step).Contains("gh attestation verify", StringComparison.Ordinal)); + int upload = attest.FindIndex(static step => Uses(step).StartsWith("actions/upload-artifact@", StringComparison.Ordinal)); + int lastAttestation = attest.FindLastIndex(static step => Uses(step).StartsWith("actions/attest@", StringComparison.Ordinal)); + Assert.True(verifyStep > lastAttestation && verifyStep < upload, "attest must verify its attestations after creating them and before uploading the bundles."); + string attestScript = Run(attest[verifyStep]); + foreach (string predicate in predicates) + { + Assert.Matches($@"(?m)@identity --predicate-type '{Regex.Escape(predicate)}' --bundle \$\w+\s*$", attestScript); + Assert.Matches($@"(?m)@identity --predicate-type '{Regex.Escape(predicate)}'\s*$", attestScript); + } + + string[] subjects = Scalar(Mapping(Step(Mapping(Mapping(Workflow.Value, "jobs"), "attest"), "provenance"), "with"), "subject-path") + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string[] expectedSubjects = ["artifacts/nuget/*.nupkg", "artifacts/nuget/*.snupkg"]; + Assert.Equal(expectedSubjects, subjects); + } + + /// + /// release.yml declares the seven package ids once, as the workflow-level PACKAGE_IDS in dependency order, and every + /// job derives its list from it (the SBOM attestation steps take their subjects by position); ci.yml declares each id + /// once in its Pack step. Both sets equal the packable projects. + /// + [Fact] + public void PackageIdsAreDeclaredOnceAndMatchThePackableProjects() + { + string[] packable = PackageMetadataTests.PackableProjects.Select(Path.GetFileNameWithoutExtension).Select(static id => id!).Order(StringComparer.Ordinal).ToArray(); + string[] declared = PackageIds(); + + Assert.Equal(declared.Length, declared.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(packable, declared.Order(StringComparer.Ordinal)); + Assert.Equal(PushOrder, declared); + + string release = File.ReadAllText(Path.Combine(RepositoryRoot.Path, WorkflowPath)); + Assert.Empty(QuotedPackageId().Matches(release)); + List offenders = []; + foreach (YamlNode key in Mapping(Workflow.Value, "jobs").Children.Keys) + { + string job = ((YamlScalarNode) key).Value!; + foreach (YamlMappingNode step in Steps(job)) + { + if (step.Children.TryGetValue(new YamlScalarNode("with"), out YamlNode? with)) + { + offenders.AddRange(((YamlMappingNode) with).Children.Values.OfType() + .Where(static value => PackageFileName().IsMatch(value.Value ?? string.Empty)) + .Select(value => $"{job}: {value.Value}")); + } + } + } + + Assert.True(offenders.Count == 0, $"Step inputs name packages instead of deriving them from PACKAGE_IDS:{Environment.NewLine}{string.Join(Environment.NewLine, offenders)}"); + string[] readers = ["verify", "attest", "publish", "verify-publication", "finalize-release"]; + foreach (string job in readers) + { + Assert.Contains("$env:PACKAGE_IDS", JobText(job), StringComparison.Ordinal); + } + + // ci.yml: every quoted package id of the file is in the Pack step's declaration, once. + WorkflowFile ci = WorkflowFile.Load(".github/workflows/ci.yml"); + string pack = Assert.Single(ci.Job("build-test").Steps, static step => step.Id == "pack").Run; + Assert.Equal(packable, QuotedIds(ci.Text, packable).Order(StringComparer.Ordinal)); + Assert.Equal(packable, QuotedIds(pack, packable).Order(StringComparer.Ordinal)); + } + + /// + /// verify-publication resolves PackageBaseAddress/3.0.0 from the nuget.org service index instead of a hard-coded + /// flat container URL, and requires the nuget.org repository signature on every served package. + /// + [Fact] + public void VerifyPublicationResolvesTheServiceIndexAndRequiresTheRepositorySignature() + { + string script = Run(Assert.Single(Steps("verify-publication"), static step => Run(step).Contains("dotnet nuget verify", StringComparison.Ordinal))); + string[] required = + [ + "Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json'", "'PackageBaseAddress/3.0.0'", "dotnet nuget verify --all", + "^Signature type: Repository", @"^Service index: https://api\.nuget\.org/v3/index\.json", "'.signature.p7s'", "^Content hash:", + "DOTNET_CLI_UI_LANGUAGE = 'en'" + ]; + foreach (string value in required) + { + Assert.Contains(value, script, StringComparison.Ordinal); + } + + Assert.DoesNotContain("v3-flatcontainer", script, StringComparison.Ordinal); + } + + /// + /// A re-run deletes every draft already on the tag with gh release delete, which resolves a draft by its tag and + /// keeps the git tag, before it creates the draft again. The id that gh release view reports is a GraphQL node id the + /// REST API rejects, so no release id reaches a REST call (PKG-07). + /// + [Fact] + public void DraftRerunDeletesTheDraftWithGhReleaseDelete() + { + string script = Run(Assert.Single(Steps("draft-release"), static step => Run(step).Contains("gh release create", StringComparison.Ordinal))); + int list = script.IndexOf("gh release list", StringComparison.Ordinal); + Match delete = GhReleaseDelete().Match(script); + int create = script.IndexOf("gh release create", StringComparison.Ordinal); + + Assert.True(list >= 0 && delete.Success && list < delete.Index && delete.Index < create, + "draft-release must list the releases of the tag, delete its drafts with 'gh release delete $env:TAG --yes', then create the draft."); + Assert.Contains("isDraft", script, StringComparison.Ordinal); + Assert.Contains("throw", script[..delete.Index], StringComparison.Ordinal); + Assert.DoesNotContain("--cleanup-tag", script, StringComparison.Ordinal); + Assert.DoesNotMatch(@"--json\s+(\S+,)?(id|databaseId)(,|\s|$)", script); + + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, WorkflowPath)); + Assert.DoesNotMatch(@"\bgh api\b[^\n]*(--method|-X)\s*DELETE", text); + Assert.DoesNotMatch(@"\bgh api\b[^\n]*releases/", text); + } + + /// + /// stage runs on every event, dry runs included, with a read-only token and no OIDC token: it extracts the SBOM each + /// package embeds and writes SHA256SUMS, and uploads them as release-staging. A release attests exactly those staged + /// files: attest needs stage, downloads its artifact and extracts no SBOM of its own. + /// + [Fact] + public void DryRunStagesTheSbomAndChecksums() + { + YamlMappingNode jobs = Mapping(Workflow.Value, "jobs"); + YamlMappingNode stage = Mapping(jobs, "stage"); + string[] needs = ["verify", "ci"]; + Assert.Equal(needs, Needs(stage)); + Assert.False(stage.Children.ContainsKey(new YamlScalarNode("if")), "stage must run on a workflow_dispatch dry run as well."); + Assert.False(stage.Children.ContainsKey(new YamlScalarNode("environment"))); + YamlMappingNode permissions = Mapping(stage, "permissions"); + Assert.Equal("read", Scalar(permissions, "contents")); + Assert.Single(permissions.Children); + Assert.DoesNotContain("secrets.", JobText("stage"), StringComparison.Ordinal); + Assert.DoesNotContain("GH_TOKEN", JobText("stage"), StringComparison.Ordinal); + + List steps = Steps("stage"); + Assert.DoesNotContain(steps, static step => Uses(step).StartsWith("actions/attest@", StringComparison.Ordinal)); + string script = string.Join('\n', steps.Select(Run)); + string[] required = + [ + "$env:PACKAGE_IDS", "_manifest/spdx_2.2/manifest.spdx.json", "'SPDX-2.2'", "WriteAllBytes", "SHA256SUMS", "Get-FileHash", + "GITHUB_STEP_SUMMARY" + ]; + foreach (string value in required) + { + Assert.Contains(value, script, StringComparison.Ordinal); + } + + Assert.DoesNotMatch(@"(?m)^\s*gh\s", script); + YamlMappingNode upload = Assert.Single(steps, static step => Uses(step).StartsWith("actions/upload-artifact@", StringComparison.Ordinal)); + Assert.Equal("release-staging", Scalar(Mapping(upload, "with"), "name")); + + Assert.Contains("stage", Needs(Mapping(jobs, "attest"))); + Assert.Contains(Steps("attest"), static step => Uses(step).StartsWith("actions/download-artifact@", StringComparison.Ordinal) + && Scalar(Mapping(step, "with"), "name") == "release-staging"); + Assert.DoesNotContain("_manifest/spdx_2.2", JobText("attest"), StringComparison.Ordinal); + } + + private static YamlMappingNode LoadWorkflow() + { + YamlStream stream = []; + using StreamReader reader = new(Path.Combine(RepositoryRoot.Path, WorkflowPath)); + stream.Load(reader); + return (YamlMappingNode) stream.Documents[0].RootNode; + } + + /// The value of a scalar node; fails for a mapping or a sequence. + private static string Text(YamlNode node) + { + return Assert.IsType(node).Value ?? string.Empty; + } + + /// Every key and scalar value of a job, joined, so an expression anywhere in the job is found. + private static string JobText(string job) + { + List scalars = []; + Stack pending = new([Mapping(Mapping(Workflow.Value, "jobs"), job)]); + while (pending.Count > 0) + { + switch (pending.Pop()) + { + case YamlScalarNode scalar: + scalars.Add(scalar.Value ?? string.Empty); + break; + case YamlSequenceNode sequence: + foreach (YamlNode item in sequence.Children) + { + pending.Push(item); + } + + break; + case YamlMappingNode mapping: + foreach ((YamlNode key, YamlNode value) in mapping.Children) + { + pending.Push(key); + pending.Push(value); + } + + break; + } + } + + return string.Join('\n', scalars); + } + + private static YamlMappingNode Mapping(YamlMappingNode parent, string key) + { + Assert.True(parent.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? node), $"{WorkflowPath} has no '{key}'."); + return Assert.IsType(node); + } + + private static string Scalar(YamlMappingNode parent, string key) + { + Assert.True(parent.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? node), $"{WorkflowPath} has no '{key}'."); + return Assert.IsType(node).Value ?? string.Empty; + } + + private static string[] Sequence(YamlMappingNode parent, string key) + { + Assert.True(parent.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? node), $"{WorkflowPath} has no '{key}'."); + return Assert.IsType(node).Children.Select(static item => ((YamlScalarNode) item).Value!).ToArray(); + } + + private static YamlMappingNode Step(YamlMappingNode job, string id) + { + YamlSequenceNode steps = Assert.IsType(job.Children[new YamlScalarNode("steps")]); + return Assert.Single(steps.Children.Cast(), + step => step.Children.TryGetValue(new YamlScalarNode("id"), out YamlNode? stepId) && Text(stepId) == id); + } + + private static string[] Needs(YamlMappingNode job) + { + if (!job.Children.TryGetValue(new YamlScalarNode("needs"), out YamlNode? needs)) + { + return []; + } + + return needs is YamlSequenceNode sequence + ? sequence.Children.Select(static item => ((YamlScalarNode) item).Value!).ToArray() + : [((YamlScalarNode) needs).Value!]; + } + + /// The ids of the workflow-level PACKAGE_IDS, in declaration order. + private static string[] PackageIds() + { + return Scalar(Mapping(Workflow.Value, "env"), "PACKAGE_IDS").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + /// Every single-quoted occurrence of one of the given package ids, repeats included. + private static string[] QuotedIds(string text, string[] ids) + { + return QuotedPackageId().Matches(text).Select(static match => match.Groups["id"].Value).Where(id => ids.Contains(id, StringComparer.Ordinal)).ToArray(); + } + + /// The steps of a job; empty for a reusable-workflow call. + private static List Steps(string job) + { + return Mapping(Mapping(Workflow.Value, "jobs"), job).Children.TryGetValue(new YamlScalarNode("steps"), out YamlNode? steps) + ? Assert.IsType(steps).Children.Cast().ToList() + : []; + } + + /// The script of a step; empty for an action step. + private static string Run(YamlMappingNode step) + { + return step.Children.TryGetValue(new YamlScalarNode("run"), out YamlNode? run) ? Text(run) : string.Empty; + } + + private static string? StepId(YamlMappingNode step) + { + return step.Children.TryGetValue(new YamlScalarNode("id"), out YamlNode? id) ? Text(id) : null; + } + + /// The action reference of a step; empty for a script step. + private static string Uses(YamlMappingNode step) + { + return step.Children.TryGetValue(new YamlScalarNode("uses"), out YamlNode? uses) ? Text(uses) : string.Empty; + } + + [GeneratedRegex(@"'(?CheatEngine\.Client(?:\.[A-Za-z.]+)?)'", RegexOptions.CultureInvariant, 1000)] + private static partial Regex QuotedPackageId(); + + [GeneratedRegex(@"\b(always|cancelled|failure)\s*\(", RegexOptions.CultureInvariant, 1000)] + private static partial Regex StatusFunction(); + + [GeneratedRegex(@"(?m)^\s*\$view = gh release view \$env:TAG --json isDraft,isImmutable\s*$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex GhReleaseView(); + + [GeneratedRegex(@"(?m)^\s*gh attestation verify\b.*$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex AttestationVerification(); + + [GeneratedRegex(@"\$identity = @\((?[^)]*)\)", RegexOptions.CultureInvariant, 1000)] + private static partial Regex IdentityDeclaration(); + + [GeneratedRegex(@"CheatEngine\.Client(\.[A-Za-z]+)*\.(\$|\{\{|[0-9])", RegexOptions.CultureInvariant, 1000)] + private static partial Regex PackageFileName(); + + [GeneratedRegex(@"\bgh api\b")] + private static partial Regex GhApi(); + + [GeneratedRegex(@"(?m)^\s*gh release delete \$env:TAG --yes\s*$")] + private static partial Regex GhReleaseDelete(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Release/RepositoryDocumentsTests.cs b/tests/CheatEngine.Client.Repository.Tests/Release/RepositoryDocumentsTests.cs new file mode 100644 index 0000000..2d75d66 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Release/RepositoryDocumentsTests.cs @@ -0,0 +1,250 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Release; + +/// The files a published repository needs exist and agree with the package metadata. +public sealed partial class RepositoryDocumentsTests +{ + private const int RegexTimeoutMilliseconds = 1000; + + private static readonly string[] ReleaseCategories = ["Added", "Changed", "Security", "Deployment"]; + + [Fact] + public void LicenseIsMitAndMatchesThePackageLicenseExpression() + { + string[] license = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, "LICENSE")); + XDocument buildProperties = XDocument.Load(Path.Combine(RepositoryRoot.Path, "Directory.Build.props")); + string? expression = buildProperties.Descendants("PackageLicenseExpression").SingleOrDefault()?.Value; + + Assert.True(license.Length > 2 && license[0] == "MIT License", + "LICENSE must start with the line 'MIT License'."); + Assert.True(expression == "MIT", + $"Directory.Build.props declares PackageLicenseExpression '{expression}', but LICENSE is the MIT license."); + } + + [Fact] + public void ChangelogHasAnUnreleasedSectionWithTheFourReleaseCategories() + { + string[] changelog = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, "CHANGELOG.md")); + int start = Array.IndexOf(changelog, "## [Unreleased]"); + Assert.True(start >= 0, "CHANGELOG.md has no '## [Unreleased]' heading; the release workflow reads that exact syntax."); + + List categories = []; + for (int index = start + 1; index < changelog.Length && !changelog[index].StartsWith("## ", StringComparison.Ordinal); index++) + { + if (changelog[index].StartsWith("### ", StringComparison.Ordinal)) + { + categories.Add(changelog[index][4..].Trim()); + } + } + + Assert.True(categories.SequenceEqual(ReleaseCategories), + $"The [Unreleased] section must list exactly {string.Join(", ", ReleaseCategories)} in that order (audit A21-17), but lists: {string.Join(", ", categories)}."); + } + + [Fact] + public void ChangelogReleasesAreDatedInIsoFormatNewestFirstAndHaveEntries() + { + string[] changelog = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, "CHANGELOG.md")); + string[] offenders = FindReleaseSectionOffenders(changelog); + + Assert.True(offenders.Length == 0, + "CHANGELOG.md releases are '## [X.Y.Z] - YYYY-MM-DD' sections in ISO 8601, newest first, each with " + + $"entries:{Environment.NewLine}{string.Join(Environment.NewLine, offenders)}"); + } + + [Fact] + public void TheReleaseSectionRulesSeeOrderDatesHeadingsAndEmptySections() + { + // The CHANGELOG holds one release, so its fact never compares two; these lines exercise every rule. + Assert.Empty(FindReleaseSectionOffenders( + [ + "# Changelog", "## [Unreleased]", "### Added", "## [1.1.0] - 2027-01-04", "- 1.1", + "## [1.1.0-rc.1] - 2027-01-02", "- candidate", "## [1.0.0] - 2026-09-25", "### Added", "- first" + ])); + + Assert.Equal( + ["line 4: [1.1.0] - 2026-09-25 is not older than the release above it; the newest release comes first"], + FindReleaseSectionOffenders( + ["## [Unreleased]", "## [1.0.0] - 2026-09-25", "- a", "## [1.1.0] - 2026-09-25", "- b"])); + Assert.Equal( + ["line 4: [1.1.0] - 2027-01-01 is not older than the release above it; the newest release comes first"], + FindReleaseSectionOffenders( + ["## [Unreleased]", "## [1.1.0-rc.1] - 2027-01-02", "- rc", "## [1.1.0] - 2027-01-01", "- b"])); + Assert.Equal(["line 4: [1.0.0] - 2027-02-01 is dated after the newer release above it"], + FindReleaseSectionOffenders( + ["## [Unreleased]", "## [1.1.0] - 2027-01-04", "- a", "## [1.0.0] - 2027-02-01", "- b"])); + Assert.Equal( + [ + "line 2: '2027-02-30' is not an ISO 8601 calendar date (YYYY-MM-DD)", + "line 4: '## 1.1.0' is not '## [X.Y.Z] - YYYY-MM-DD'; the release workflow reads every level-2 " + + "heading as the end of the section above it", + "line 6: [1.0.0] - 2026-09-25 has no entry; its body becomes the GitHub release notes" + ], + FindReleaseSectionOffenders( + [ + "## [Unreleased]", "## [1.2.0] - 2027-02-30", "- a", "## 1.1.0", "- b", "## [1.0.0] - 2026-09-25", + "### Added", "" + ])); + Assert.Equal( + [ + "line 3: '## [Unreleased]' must appear once, above every release", + "line 4: '## [Unreleased]' must appear once, above every release" + ], + FindReleaseSectionOffenders(["## [1.0.0] - 2026-09-25", "- a", "## [Unreleased]", "## [Unreleased]"])); + Assert.Equal(["no '## [Unreleased]' heading; the release workflow reads that exact syntax"], + FindReleaseSectionOffenders(["# Changelog", "## [1.0.0] - 2026-09-25", "- a"])); + } + + [Fact] + public void ReleasingDocumentsTheTrustedPublishingPolicyForTheClientPackageGlob() + { + string releasing = File.ReadAllText(Path.Combine(RepositoryRoot.Path, "RELEASING.md")); + string[] required = ["`CheatEngine.Client*`", "`release.yml`", "`nuget`", "`NUGET_USER`", "`CheatEngineNet`", "`CheatEngine.Client`"]; + List missing = []; + foreach (string value in required) + { + if (!releasing.Contains(value, StringComparison.Ordinal)) + { + missing.Add(value); + } + } + + Assert.True(missing.Count == 0, + $"RELEASING.md must document the nuget.org trusted publishing policy; it does not mention: {string.Join(", ", missing)}."); + } + + [Fact] + public void ReleasingNamesTheOrganizationAsPolicyOwnerAndItsMemberAsNuGetUser() + { + string[] releasing = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, "RELEASING.md")); + string[] owners = [.. releasing.Select(static line => PolicyOwnerRow().Match(line)) + .Where(static match => match.Success).Select(static match => match.Groups["owner"].Value)]; + string[] users = [.. releasing.Select(static line => NuGetUserSecret().Match(line)) + .Where(static match => match.Success).Select(static match => match.Groups["user"].Value)]; + + // The policy belongs to the nuget.org organization, so it survives a change of maintainer; NuGet/login still + // needs the profile name of the member who created it, never the organization name or an e-mail address. + Assert.True(owners.SequenceEqual(["`CheatEngine` (organization)"], StringComparer.Ordinal), + "RELEASING.md must have one trusted publishing row '| Policy owner | `CheatEngine` (organization) |', " + + $"found: {string.Join(", ", owners)}."); + Assert.True(users.SequenceEqual(["AriusII"], StringComparer.Ordinal), + "RELEASING.md must set the environment secret `NUGET_USER` to `AriusII` once, found: " + + $"{string.Join(", ", users)}."); + } + + /// + /// The breaches of the release section rules in : one ## [Unreleased] above + /// every release; each release a ## [X.Y.Z] - YYYY-MM-DD heading with a real ISO 8601 date, older than + /// the release above it (a prerelease precedes the release of its version) and not dated after it; and each + /// release section with an entry. + /// + private static string[] FindReleaseSectionOffenders(string[] changelog) + { + List offenders = []; + bool unreleasedSeen = false; + (Version Core, DateOnly Date)? newer = null; + for (int index = 0; index < changelog.Length; index++) + { + string line = changelog[index]; + if (!line.StartsWith("## ", StringComparison.Ordinal)) + { + continue; + } + + if (line == "## [Unreleased]") + { + if (unreleasedSeen || newer is not null) + { + offenders.Add($"line {index + 1}: '## [Unreleased]' must appear once, above every release"); + } + + unreleasedSeen = true; + continue; + } + + Match heading = ReleaseHeading().Match(line); + if (!heading.Success) + { + offenders.Add($"line {index + 1}: '{line}' is not '## [X.Y.Z] - YYYY-MM-DD'; the release " + + "workflow reads every level-2 heading as the end of the section above it"); + continue; + } + + string date = heading.Groups["date"].Value; + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, + out DateOnly released)) + { + offenders.Add($"line {index + 1}: '{date}' is not an ISO 8601 calendar date (YYYY-MM-DD)"); + continue; + } + + Version core = Version.Parse(heading.Groups["core"].Value); + bool isPrerelease = heading.Groups["prerelease"].Success; + if (newer is { } above) + { + // A prerelease precedes the release of its version; two prereleases of one version are not + // ordered here. + if (core > above.Core || (core == above.Core && !isPrerelease)) + { + offenders.Add($"line {index + 1}: {line[3..]} is not older than the release above it; the newest " + + "release comes first"); + } + + if (released > above.Date) + { + offenders.Add($"line {index + 1}: {line[3..]} is dated after the newer release above it"); + } + } + + if (!SectionHasEntries(changelog, index)) + { + offenders.Add($"line {index + 1}: {line[3..]} has no entry; its body becomes the GitHub release notes"); + } + + newer = (core, released); + } + + if (!unreleasedSeen) + { + offenders.Add("no '## [Unreleased]' heading; the release workflow reads that exact syntax"); + } + + return [.. offenders]; + } + + /// Whether a release section holds a line other than a blank line or a category heading. + private static bool SectionHasEntries(string[] changelog, int heading) + { + for (int index = heading + 1; + index < changelog.Length && !changelog[index].StartsWith("## ", StringComparison.Ordinal); + index++) + { + string line = changelog[index].Trim(); + if (line.Length > 0 && !line.StartsWith("### ", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// A release heading as the release workflow reads it: ## [X.Y.Z(-prerelease)] - date. + [GeneratedRegex(@"^## \[(?\d+\.\d+\.\d+)(?-[0-9A-Za-z.-]+)?\] - (?\S+)$", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ReleaseHeading(); + + /// The Policy owner row of the trusted publishing table. + [GeneratedRegex(@"^\|\s*Policy owner\s*\|\s*(?[^|]*?)\s*\|\s*$", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex PolicyOwnerRow(); + + /// The value the setup gives the NUGET_USER environment secret. + [GeneratedRegex(@"secret `NUGET_USER`:\*\*\s*`(?[^`]+)`", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex NuGetUserSecret(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Solution/SolutionInventoryTests.cs b/tests/CheatEngine.Client.Repository.Tests/Solution/SolutionInventoryTests.cs new file mode 100644 index 0000000..a06f8ee --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Solution/SolutionInventoryTests.cs @@ -0,0 +1,66 @@ +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.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 OutOfSolution = new(StringComparer.Ordinal) + { + ["templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj"] = + "Template content: it is packed as text and built only after 'dotnet new' instantiates it in the package smoke test." + }; + + [Fact] + public void EveryProjectOnDiskIsInTheSolutionOrExplicitlyExcluded() + { + HashSet listed = ReadSolutionProjects(); + List missing = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (!listed.Contains(project) && !OutOfSolution.ContainsKey(project)) + { + missing.Add(project); + } + } + + Assert.True(missing.Count == 0, + $"Add these projects to CheatEngine.Client.slnx (dotnet sln add) or justify them in {nameof(OutOfSolution)}: {string.Join(", ", missing)}"); + } + + [Fact] + public void EveryProjectInTheSolutionExistsAndNoExclusionIsStale() + { + HashSet listed = ReadSolutionProjects(); + foreach (string project in listed) + { + Assert.True(File.Exists(Path.Combine(RepositoryRoot.Path, project)), + $"CheatEngine.Client.slnx lists '{project}', which does not exist."); + } + + foreach (string excluded in 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(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.Client.Repository.Tests/SourcePolicy/AotProbeCoverageTests.cs b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/AotProbeCoverageTests.cs new file mode 100644 index 0000000..3882ff8 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/AotProbeCoverageTests.cs @@ -0,0 +1,356 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.SourcePolicy; + +/// +/// The Native AOT probe calls the whole public surface of CheatEngine.Client.Fluent, rather than naming its types +/// with typeof only: the probe is published as a Native AOT executable and run by the delivery gate, so +/// every Fluent member it calls is compiled, reached and executed under Native AOT. +/// +/// +/// +/// The surface is read from the Fluent PublicAPI baselines, so a new public member fails this test until the +/// probe calls it. The check is textual, over a fixed layout of AotProbeFluentCalls.cs with its comments +/// removed: each public Fluent type has a static bool Exercise<Type>( method (generic arity +/// dropped) whose body holds at least as many .Member accesses as each member of that type has public +/// signatures; Run calls every Exercise method; and the probe's entry point returns 1 when +/// AotProbeFluentCalls.Run() returns . +/// +/// +/// Counting call sites cannot tell which overload a call binds to: two calls of one overload satisfy a member +/// with two signatures. The probe therefore keeps one call per overload, each with an argument of that +/// overload's type, and a review of AotProbeFluentCalls.cs checks it. +/// +/// +/// Whether each call returns what the probe's in-process fakes hold is checked by the probe itself, whose exit +/// code the delivery gate checks after the Native AOT publish. +/// +/// +public sealed partial class AotProbeCoverageTests +{ + private const string FluentCallsSource = "tests/CheatEngine.Client.AotProbe/AotProbeFluentCalls.cs"; + private const string ProbeEntryPoint = "tests/CheatEngine.Client.AotProbe/Program.cs"; + private const string FluentRunCall = "AotProbeFluentCalls.Run()"; + private const string ExercisePrefix = "Exercise"; + private const int RegexTimeoutMilliseconds = 1000; + + private static readonly string[] FluentBaselines = + [ + "libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt", + "libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt" + ]; + + /// A PublicAPI excerpt in the baseline format, with a short namespace. + private static readonly string[] SampleBaseline = + [ + "#nullable enable", + "", + "F.AobScanBuilder", + "F.AobScanBuilder.AobScanBuilder() -> void", + "F.AobScanBuilder.InModule(string! moduleName) -> F.AobScanBuilder", + "F.AobScanBuilder.InModule(CheatEngine.SDK.Engine.Inspection.ModuleName module) -> F.AobScanBuilder", + "F.AobScanBuilder.Pattern.get -> F.AobPattern", + "F.AobFirstMatchBuilder", + "F.AobFirstMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default) -> A?", + "F.AobSingleMatchBuilder", + "F.AobSingleMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default) -> A", + "F.MemoryPrimitiveBatchBuilder", + "F.MemoryPrimitiveBatchBuilder.Read(System.ReadOnlySpan addresses) -> System.ImmutableArray", + "static F.CheatEngineMemoryFluentExtensions.Batch(this F.IMemoryClient! memory) -> F.Batch", + "[CECLIENT5001]F.AobScanBuilder.Take(int maximumResults) -> F.AobManyMatchBuilder" + ]; + + [Fact] + public void TheProbeCallsEveryPublicFluentMember() + { + List lines = []; + foreach (string baseline in FluentBaselines) + { + lines.AddRange(File.ReadAllLines(Path.Combine(RepositoryRoot.Path, baseline))); + } + + SortedDictionary> surface = ReadSurface(lines); + string source = File.ReadAllText(Path.Combine(RepositoryRoot.Path, FluentCallsSource)); + List gaps = FindGaps(source, surface); + + Assert.True(surface.Count > 0, "The Fluent baselines declare no public type: the check would pass vacuously."); + Assert.True(gaps.Count == 0, + $"{FluentCallsSource} must call every public Fluent member under Native AOT:{Environment.NewLine}" + + string.Join(Environment.NewLine, gaps)); + } + + [Fact] + public void TheProbeEntryPointFailsWhenAFluentCallMisbehaves() + { + string entryPoint = File.ReadAllText(Path.Combine(RepositoryRoot.Path, ProbeEntryPoint)); + string guard = $"if (!{FluentRunCall})"; + int call = entryPoint.IndexOf(guard, StringComparison.Ordinal); + + Assert.True(call >= 0, $"{ProbeEntryPoint} must test the result of {FluentRunCall}."); + string branch = entryPoint[(call + guard.Length)..].TrimStart(); + Assert.StartsWith("{", branch, StringComparison.Ordinal); + Assert.StartsWith("return 1;", branch[1..].TrimStart(), StringComparison.Ordinal); + } + + [Fact] + public void TheSurfaceReaderCountsSignaturesAndSkipsTypeLinesAndConstructors() + { + SortedDictionary> surface = ReadSurface(SampleBaseline); + + Assert.Equal( + [ + "AobFirstMatchBuilder", "AobScanBuilder", "AobSingleMatchBuilder", "CheatEngineMemoryFluentExtensions", + "MemoryPrimitiveBatchBuilder" + ], + surface.Keys); + Assert.Equal(["InModule", "Pattern", "Take"], surface["AobScanBuilder"].Keys); + Assert.Equal(2, surface["AobScanBuilder"]["InModule"]); + Assert.Equal(1, surface["AobScanBuilder"]["Pattern"]); + Assert.Equal(1, surface["MemoryPrimitiveBatchBuilder"]["Read"]); + Assert.Equal(1, surface["CheatEngineMemoryFluentExtensions"]["Batch"]); + } + + [Fact] + public void TheGapFinderReportsAMissingMethodCallSiteAndRunCallAndIgnoresComments() + { + SortedDictionary> surface = + ReadSurface(SampleBaseline.Where(static line => line.Contains("Aob", StringComparison.Ordinal))); + const string Source = """ + internal static bool Run() + { + return ExerciseAobScanBuilder(scanner) /* && ExerciseAobFirstMatchBuilder(scanner) */; + } + + private static bool ExerciseAobScanBuilder(AotProbePatternScanner scanner) + { + AobScanBuilder scan = scanner.Aob("90").InModule("game.exe").Take(1); + // scan = scan.InModule(new ModuleName("game.exe")); + return scan.Pattern.IsWildcardOnly && scan.PatternLength == 1; + } + + private static bool ExerciseAobFirstMatchBuilder(AotProbePatternScanner scanner) + { + return scanner.Aob("90").FirstOrNone().Execute() is null; + } + """; + + List gaps = FindGaps(Source, surface); + + Assert.Equal(3, gaps.Count); + Assert.Contains(gaps, static gap => gap.Contains("no 'static bool ExerciseAobSingleMatchBuilder('", + StringComparison.Ordinal)); + Assert.Contains(gaps, static gap => gap.Contains("AobScanBuilder.InModule", StringComparison.Ordinal)); + Assert.Contains(gaps, static gap => gap == "Run does not call ExerciseAobFirstMatchBuilder."); + } + + /// + /// Reads the public types of a PublicAPI baseline and, per type, how many public signatures each member name + /// has. Constructors are skipped (a value-type builder always has one); a property counts once. + /// + private static SortedDictionary> ReadSurface(IEnumerable lines) + { + SortedDictionary> surface = new(StringComparer.Ordinal); + HashSet properties = new(StringComparer.Ordinal); + foreach (string line in lines) + { + string entry = StripModifiers(line.Trim()); + if (entry.Length == 0 || entry.StartsWith('#')) + { + continue; + } + + int arrow = entry.IndexOf(" -> ", StringComparison.Ordinal); + string signature = arrow < 0 ? entry : entry[..arrow]; + int parameters = signature.IndexOf('(', StringComparison.Ordinal); + bool isProperty = parameters < 0 && (signature.EndsWith(".get", StringComparison.Ordinal) + || signature.EndsWith(".set", StringComparison.Ordinal) + || signature.EndsWith(".init", StringComparison.Ordinal)); + string path = signature; + if (parameters >= 0) + { + path = signature[..parameters]; + } + else if (isProperty) + { + path = signature[..signature.LastIndexOf('.')]; + } + + string[] segments = SplitOutsideTypeArguments(path); + if (parameters < 0 && !isProperty) + { + _ = GetMembers(surface, WithoutTypeArguments(segments[^1])); + continue; + } + + string type = WithoutTypeArguments(segments[^2]); + string member = WithoutTypeArguments(segments[^1]); + if (member == type || (isProperty && !properties.Add($"{type}.{member}"))) + { + continue; + } + + SortedDictionary members = GetMembers(surface, type); + members[member] = members.GetValueOrDefault(member) + 1; + } + + return surface; + } + + /// Lists each missing Exercise method, call site, or Run call of the probe. + /// + /// Comments are removed first, a // or /* inside a string literal included: removing too much can + /// only report a gap that does not exist, never hide one. + /// + private static List FindGaps(string source, SortedDictionary> surface) + { + List gaps = []; + source = Comments().Replace(source, string.Empty); + string? run = MethodBody(source, "Run"); + foreach ((string type, SortedDictionary members) in surface) + { + string exercise = ExercisePrefix + type; + if (MethodBody(source, exercise) is not { } body) + { + gaps.Add($"There is no 'static bool {exercise}(' method for the public Fluent type {type}."); + continue; + } + + if (run is null || !run.Contains(exercise + "(", StringComparison.Ordinal)) + { + gaps.Add($"Run does not call {exercise}."); + } + + foreach ((string member, int signatures) in members) + { + int calls = CountMemberAccesses(body, member); + if (calls < signatures) + { + gaps.Add($"{exercise} has {calls} '.{member}' call site(s) for {type}.{member}, which has " + + $"{signatures} public signature(s): call each one."); + } + } + } + + return gaps; + } + + private static SortedDictionary GetMembers( + SortedDictionary> surface, string type) + { + if (!surface.TryGetValue(type, out SortedDictionary? members)) + { + members = new SortedDictionary(StringComparer.Ordinal); + surface.Add(type, members); + } + + return members; + } + + /// Removes the experimental diagnostic prefix and the modifiers before a PublicAPI signature. + private static string StripModifiers(string entry) + { + string stripped = entry.StartsWith('[') ? entry[(entry.IndexOf(']', StringComparison.Ordinal) + 1)..] : entry; + string[] modifiers = ["~", "static ", "abstract ", "virtual ", "override ", "const ", "readonly ", "sealed "]; + bool removed = true; + while (removed) + { + removed = false; + foreach (string modifier in modifiers) + { + if (stripped.StartsWith(modifier, StringComparison.Ordinal)) + { + stripped = stripped[modifier.Length..]; + removed = true; + } + } + } + + return stripped; + } + + /// Splits a dotted name at the dots that are not inside a type argument list. + private static string[] SplitOutsideTypeArguments(string path) + { + List segments = []; + int depth = 0; + int start = 0; + for (int index = 0; index < path.Length; index++) + { + switch (path[index]) + { + case '<': + depth++; + break; + case '>': + depth--; + break; + case '.' when depth == 0: + segments.Add(path[start..index]); + start = index + 1; + break; + } + } + + segments.Add(path[start..]); + return [.. segments]; + } + + private static string WithoutTypeArguments(string name) + { + int typeArguments = name.IndexOf('<', StringComparison.Ordinal); + return typeArguments < 0 ? name : name[..typeArguments]; + } + + /// Returns the body of the first static bool method with this name, or null. + private static string? MethodBody(string source, string name) + { + int declaration = source.IndexOf($"static bool {name}(", StringComparison.Ordinal); + int open = declaration < 0 ? -1 : source.IndexOf('{', declaration); + if (open < 0) + { + return null; + } + + int depth = 0; + for (int index = open; index < source.Length; index++) + { + depth += source[index] switch + { + '{' => 1, + '}' => -1, + _ => 0 + }; + if (depth == 0) + { + return source[(open + 1)..index]; + } + } + + return null; + } + + /// Counts the .member accesses whose name is not the prefix of a longer identifier. + private static int CountMemberAccesses(string body, string member) + { + int count = 0; + string access = "." + member; + for (int index = body.IndexOf(access, StringComparison.Ordinal); index >= 0; + index = body.IndexOf(access, index + access.Length, StringComparison.Ordinal)) + { + int next = index + access.Length; + if (next == body.Length || !(char.IsLetterOrDigit(body[next]) || body[next] == '_')) + { + count++; + } + } + + return count; + } + + /// A C# line comment or delimited comment. + [GeneratedRegex(@"//[^\r\n]*|/\*.*?\*/", RegexOptions.Singleline | RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex Comments(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/ErrorTextClassificationPolicyTests.cs b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/ErrorTextClassificationPolicyTests.cs new file mode 100644 index 0000000..8d98dbd --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/ErrorTextClassificationPolicyTests.cs @@ -0,0 +1,59 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.SourcePolicy; + +/// +/// Client failure kinds must not depend on error text (A07-22, A24-24): a Cheat Engine or Lua message changes with the +/// host language and version, so Client libraries classify by type and status only. +/// +public sealed partial class ErrorTextClassificationPolicyTests +{ + [Fact] + public void ClientNeverClassifiesFailuresByErrorText() + { + List violations = []; + int inspectedFiles = 0; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.cs") + .Where(static path => path.StartsWith("libs/", StringComparison.Ordinal))) + { + inspectedFiles++; + string[] lines = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, file)); + for (int index = 0; index < lines.Length; index++) + { + if (ClassifiesByMessage(lines[index])) + { + violations.Add($"{file}:{index + 1}: {lines[index].Trim()}"); + } + } + } + + Assert.True(inspectedFiles > 0, "No Client library source was inspected."); + Assert.True(violations.Count == 0, + "Classify failures by exception type or SDK status, never by message text:" + Environment.NewLine + + string.Join(Environment.NewLine, violations)); + } + + [Theory] + [InlineData("if (exception.Message.Contains(\"not found\")) {", true)] + [InlineData("bool missing = error.Message.StartsWith(\"attempt to\", StringComparison.Ordinal);", true)] + [InlineData("int at = failure.Message.IndexOf(\"nil\");", true)] + [InlineData("if (luaError.Message == \"timeout\")", true)] + [InlineData("_ = e.Message.EndsWith(\".\") || e.Message.Equals(\"x\");", true)] + [InlineData("luaMessage = LuaError.FromStack(state, status).Message;", false)] + [InlineData("failure = new CheatEngineFailure(kind, operation, exception.Message, exception);", false)] + [InlineData("string text = $\"{primary.Message} The cleanup also failed.\";", false)] + public void MessageClassificationDetectorFindsComparisonsButNotMessageBuilding(string line, bool expected) + { + Assert.Equal(expected, ClassifiesByMessage(line)); + } + + private static bool ClassifiesByMessage(string line) + { + return MessageComparison().IsMatch(line); + } + + [GeneratedRegex(@"\.Message\s*(\.\s*(Contains|StartsWith|EndsWith|IndexOf|Equals)\s*\(|[!=]=)")] + private static partial Regex MessageComparison(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/InterfaceStabilityRemarkTests.cs b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/InterfaceStabilityRemarkTests.cs new file mode 100644 index 0000000..afd0d45 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/InterfaceStabilityRemarkTests.cs @@ -0,0 +1,111 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.SourcePolicy; + +/// +/// The 1.x versioning policy depends on one fact per public interface: Call-only interfaces (the Client +/// implements them) may gain members in a minor release, Implementable interfaces (applications implement +/// them) are frozen for 1.x. Every public interface states which it is, and the versioning sections of the root and +/// facade READMEs name exactly the implementable ones. +/// +public sealed partial class InterfaceStabilityRemarkTests +{ + private const int RegexTimeoutMilliseconds = 1000; + private const string CallOnlyMarker = "Call-only."; + private const string ImplementableMarker = "Implementable."; + private const string FrozenBullet = "- **Frozen for all of 1.x:**"; + + private static readonly string[] VersioningReadmes = ["README.md", "src/CheatEngine.Client/README.md"]; + + [Fact] + public void EveryPublicInterfaceStatesWhetherItIsCallOnlyOrImplementable() + { + IReadOnlyList interfaces = ReadPublicInterfaces(); + string[] offenders = + [ + .. interfaces + .Where(static type => type.CallOnly == type.Implementable) + .Select(static type => $"{type.Path}: {type.Name}") + ]; + + Assert.True(interfaces.Count > 0, "No public interface was found; the policy test would pass vacuously."); + Assert.True(offenders.Length == 0, + $"Each public interface needs exactly one of '{CallOnlyMarker}' or '{ImplementableMarker}' in its " + + "documentation remarks:" + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void TheVersioningSectionsNameExactlyTheImplementableInterfaces() + { + string[] implementable = + [ + .. ReadPublicInterfaces() + .Where(static type => type.Implementable) + .Select(static type => type.Name) + .Order(StringComparer.Ordinal) + ]; + + Assert.NotEmpty(implementable); + foreach (string readme in VersioningReadmes) + { + string[] lines = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, readme)); + int start = Array.FindIndex(lines, static line => line.StartsWith(FrozenBullet, StringComparison.Ordinal)); + Assert.True(start >= 0, $"{readme} has no '{FrozenBullet}' bullet in its versioning section."); + int end = Array.FindIndex(lines, start + 1, static line => !line.StartsWith(" ", StringComparison.Ordinal)); + string bullet = string.Join(' ', lines[start..end]); + string[] named = + [ + .. InterfaceName().Matches(bullet) + .Select(static match => match.Groups["name"].Value) + .Order(StringComparer.Ordinal) + ]; + + Assert.Equal(implementable, named); + } + } + + private static List ReadPublicInterfaces() + { + List interfaces = []; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.cs") + .Where(static path => path.StartsWith("libs/", StringComparison.Ordinal) || + path.StartsWith("src/", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal)) + { + string[] lines = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, file)); + for (int index = 0; index < lines.Length; index++) + { + Match declaration = PublicInterfaceDeclaration().Match(lines[index]); + if (!declaration.Success) + { + continue; + } + + int first = index; + while (first > 0 && (lines[first - 1].StartsWith("///", StringComparison.Ordinal) || + lines[first - 1].StartsWith('['))) + { + first--; + } + + string documentation = string.Join('\n', lines[first..index]); + interfaces.Add(new PublicInterface(file, declaration.Groups["name"].Value, + documentation.Contains(CallOnlyMarker, StringComparison.Ordinal), + documentation.Contains(ImplementableMarker, StringComparison.Ordinal))); + } + } + + return interfaces; + } + + [GeneratedRegex(@"^public (?:partial )?interface (?I\w+)", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex PublicInterfaceDeclaration(); + + [GeneratedRegex(@"`(?I[A-Z]\w+)(?:<[^`]*>)?`", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex InterfaceName(); + + private sealed record PublicInterface(string Path, string Name, bool CallOnly, bool Implementable); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/SingleFileSuppressionTests.cs b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/SingleFileSuppressionTests.cs new file mode 100644 index 0000000..513dded --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/SingleFileSuppressionTests.cs @@ -0,0 +1,68 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.SourcePolicy; + +/// +/// IL3000, the single-file warning on an assembly file location, is suppressed exactly once in the repository: on +/// the getter of CheatEnginePluginBuilder.PluginDirectory in Hosting, under ADR-02 (Cheat Engine loads a +/// managed plugin from its deployment folder, never from a single-file bundle). The shipped code, the template and +/// the qualification harness find their plugin folder through that property, and any other file-location read that +/// raises IL3000 must be designed, not suppressed. Test and fixture code, which is never trimmed or published as a +/// single file, raises no IL3000 and may read Assembly.Location directly. +/// +public sealed partial class SingleFileSuppressionTests +{ + private const string PluginBuilderSource = "libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs"; + + /// This policy test, which names the diagnostic in order to look for it. + private const string PolicySource = + $"tests/CheatEngine.Client.Repository.Tests/SourcePolicy/{nameof(SingleFileSuppressionTests)}.cs"; + + private static readonly string[] SourcePatterns = ["*.cs", "*.csproj", "*.props", "*.targets", "*.editorconfig"]; + + [Fact] + public void TheRepositorySuppressesIL3000ExactlyOnceForThePluginDirectory() + { + List hits = []; + foreach (string file in SourcePatterns.SelectMany(RepositoryRoot.EnumerateSourceFiles) + .Where(static path => !string.Equals(path, PolicySource, StringComparison.Ordinal))) + { + string[] lines = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, file)); + for (int index = 0; index < lines.Length; index++) + { + if (Il3000().IsMatch(lines[index])) + { + hits.Add($"{file}:{index + 1}: {lines[index].Trim()}"); + } + } + } + + string hit = Assert.Single(hits); + Assert.StartsWith(PluginBuilderSource + ":", hit, StringComparison.Ordinal); + Assert.Contains("[UnconditionalSuppressMessage(\"SingleFile\", \"IL3000:", hit, StringComparison.Ordinal); + } + + [Fact] + public void TheSuppressionCoversOnlyThePluginDirectoryGetterAndCitesAdr02() + { + string source = File.ReadAllText(Path.Combine(RepositoryRoot.Path, PluginBuilderSource)); + int property = source.IndexOf("public string PluginDirectory", StringComparison.Ordinal); + int suppression = source.IndexOf("IL3000", StringComparison.Ordinal); + Match getterBody = GetterBody().Match(source, Math.Max(suppression, 0)); + int getter = getterBody.Success ? getterBody.Index : -1; + int nextMember = source.IndexOf("internal ServiceProvider BuildServiceProvider", StringComparison.Ordinal); + + Assert.True(property >= 0 && property < suppression && suppression < getter && getter < nextMember, + "The IL3000 suppression must sit on the PluginDirectory getter."); + Assert.Contains("ADR-02", source[suppression..getter], StringComparison.Ordinal); + } + + [GeneratedRegex(@"\bIL3000\b")] + private static partial Regex Il3000(); + + /// The body of an accessor; a word of the justification such as "target" never matches it. + [GeneratedRegex(@"\bget\s*\{")] + private static partial Regex GetterBody(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/TemplateLoggingPolicyTests.cs b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/TemplateLoggingPolicyTests.cs new file mode 100644 index 0000000..070f55f --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/SourcePolicy/TemplateLoggingPolicyTests.cs @@ -0,0 +1,157 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.SourcePolicy; + +/// +/// Enforces the Q46 redaction policy on the plugin template: the template is packed as source and only compiled after +/// dotnet new, so its log events are checked from the committed text. +/// +public sealed partial class TemplateLoggingPolicyTests +{ + private const string TemplateContentRoot = "templates/CheatEngine.Client.Templates/content/"; + + private static readonly HashSet SensitiveTypeNames = new(StringComparer.Ordinal) + { + "Address", + "SymbolExpression", + "ModuleName", + "CheatEngineFailure", + "LuaScript", + "FileInfo", + "FileSystemInfo" + }; + + [Fact] + [Trait("Qualification", "Q46")] + public void TemplateLogsNoAddressesValuesOrRawFailures() + { + List violations = []; + int events = 0; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.cs") + .Where(static path => path.StartsWith(TemplateContentRoot, StringComparison.Ordinal))) + { + string source = File.ReadAllText(Path.Combine(RepositoryRoot.Path, file)); + foreach (LogEvent logEvent in FindLogEvents(source)) + { + events++; + violations.AddRange(FindViolations(logEvent).Select(violation => $"{file}: {violation}")); + } + } + + Assert.True(events > 0, "No LoggerMessage event was found in the template; the policy would pass vacuously."); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void TemplateLoggingParserRejectsSensitiveParametersAndAcceptsSafeOnes() + { + const string Sample = """ + [LoggerMessage(Level = LogLevel.Information, Message = "Read {Address} (value).")] + private static partial void LeakAddress(ILogger logger, Address address); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {Operation}: {Reason}")] + private static partial void LeakFailure(ILogger logger, string operation, CheatEngineFailure reason); + + [LoggerMessage(3, LogLevel.Warning, "Failed.")] + internal static partial void LeakException(ILogger logger, InvalidOperationException error); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Loaded {ScriptSource}.")] + private static partial void LeakScript(ILogger logger, string scriptSource); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Count {Count}; kind {Kind}.")] + private static partial void SafeEvent(ILogger logger, int count, CheatEngineFailureKind kind, + Dictionary totals); + """; + + LogEvent[] events = FindLogEvents(Sample).ToArray(); + + Assert.Equal(["LeakAddress", "LeakFailure", "LeakException", "LeakScript", "SafeEvent"], + events.Select(static logEvent => logEvent.Name)); + Assert.All(events.Where(static logEvent => logEvent.Name.StartsWith("Leak", StringComparison.Ordinal)), + static logEvent => Assert.NotEmpty(FindViolations(logEvent))); + Assert.Empty(FindViolations(events.Single(static logEvent => logEvent.Name == "SafeEvent"))); + } + + private static IEnumerable FindLogEvents(string source) + { + foreach (Match match in LoggerMessageDeclaration().Matches(source)) + { + yield return new LogEvent(match.Groups["name"].Value, SplitParameters(match.Groups["parameters"].Value)); + } + } + + private static IEnumerable FindViolations(LogEvent logEvent) + { + foreach ((string type, string name) in logEvent.Parameters) + { + string simpleType = SimpleTypeName(type); + if (SensitiveTypeNames.Contains(simpleType) || + simpleType.EndsWith("Exception", StringComparison.Ordinal)) + { + yield return $"{logEvent.Name} parameter '{name}' has user-data type '{type}'."; + } + else if (simpleType == "string" && SensitiveName().IsMatch(name)) + { + yield return $"{logEvent.Name} string parameter '{name}' is named like user data."; + } + } + } + + /// Splits a parameter list at top-level commas, ignoring commas inside generic argument lists. + private static (string Type, string Name)[] SplitParameters(string parameters) + { + List<(string Type, string Name)> result = []; + int depth = 0; + int start = 0; + for (int index = 0; index <= parameters.Length; index++) + { + char current = index < parameters.Length ? parameters[index] : ','; + if (current == '<') + { + depth++; + } + else if (current == '>') + { + depth--; + } + else if (current == ',' && depth == 0) + { + string parameter = AttributePrefix().Replace(parameters[start..index], string.Empty).Trim(); + start = index + 1; + if (parameter.Length == 0) + { + continue; + } + + int separator = parameter.LastIndexOf(' '); + result.Add((parameter[..separator].Trim(), parameter[(separator + 1)..])); + } + } + + return [.. result]; + } + + private static string SimpleTypeName(string type) + { + string withoutNullable = type.TrimEnd('?'); + int genericStart = withoutNullable.IndexOf('<', StringComparison.Ordinal); + string withoutGenerics = genericStart < 0 ? withoutNullable : withoutNullable[..genericStart]; + int namespaceEnd = withoutGenerics.LastIndexOf('.'); + return namespaceEnd < 0 ? withoutGenerics : withoutGenerics[(namespaceEnd + 1)..]; + } + + [GeneratedRegex(@"\[LoggerMessage\b.*?partial\s+void\s+(?\w+)\s*\((?[^)]*)\)", + RegexOptions.Singleline)] + private static partial Regex LoggerMessageDeclaration(); + + [GeneratedRegex(@"\[[^\]]*\]")] + private static partial Regex AttributePrefix(); + + [GeneratedRegex("path|file|script|source|message|expression", RegexOptions.IgnoreCase)] + private static partial Regex SensitiveName(); + + private sealed record LogEvent(string Name, (string Type, string Name)[] Parameters); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Toolchain/TestProfileTests.cs b/tests/CheatEngine.Client.Repository.Tests/Toolchain/TestProfileTests.cs new file mode 100644 index 0000000..0cc38d5 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Toolchain/TestProfileTests.cs @@ -0,0 +1,61 @@ +using CheatEngine.Client.Repository.Tests.Infrastructure; +using CheatEngine.Client.Repository.Tests.Workflows; + +namespace CheatEngine.Client.Repository.Tests.Toolchain; + +/// +/// CI runs every test module in one dotnet test --solution call with a single option set. Microsoft.Testing.Platform +/// fails a module with exit code 5 when an option belongs to an extension the module does not reference, so the test +/// profile (eng/Tests.props) must give every *.Tests project the extension of every option CI passes. +/// +public sealed class TestProfileTests +{ + /// The options of the CI test command and the Microsoft.Testing.Platform extension that owns each. + private static readonly Dictionary OptionOwners = new(StringComparer.Ordinal) + { + ["--report-trx"] = "Microsoft.Testing.Extensions.TrxReport", + ["--coverage"] = "Microsoft.Testing.Extensions.CodeCoverage", + ["--report-gh"] = "Microsoft.Testing.Extensions.GitHubActionsReport", + ["--hangdump"] = "Microsoft.Testing.Extensions.HangDump", + ["--crashdump"] = "Microsoft.Testing.Extensions.CrashDump" + }; + + [Fact] + public void TestModulesReferenceEveryExtensionTheCiCommandUses() + { + WorkflowStep test = Assert.Single(WorkflowFile.Load(".github/workflows/ci.yml").Job("build-test").Steps, + static step => step.Run.Contains("dotnet test", StringComparison.Ordinal)); + IReadOnlyList tokens = Yaml.Tokens(test.Run); + + XDocument profile = XDocument.Load(Path.Combine(RepositoryRoot.Path, "eng/Tests.props")); + XElement testModules = Assert.Single(profile.Root!.Elements("ItemGroup"), + static group => ((string?) group.Attribute("Condition") ?? string.Empty).Contains(".EndsWith('.Tests')", + StringComparison.Ordinal)); + HashSet referenced = testModules.Elements("PackageReference") + .Select(static reference => (string?) reference.Attribute("Include") ?? string.Empty) + .ToHashSet(StringComparer.Ordinal); + + XDocument packages = XDocument.Load(Path.Combine(RepositoryRoot.Path, "Directory.Packages.props")); + HashSet versioned = packages.Descendants("PackageVersion") + .Select(static version => (string?) version.Attribute("Include") ?? string.Empty) + .ToHashSet(StringComparer.Ordinal); + + foreach ((string option, string package) in OptionOwners) + { + Assert.True(tokens.Contains(option), + $"The CI test command no longer passes {option}; remove it from this table (and {package} from eng/Tests.props if nothing else needs it)."); + Assert.True(referenced.Contains(package), + $"CI passes {option}, which {package} owns, but eng/Tests.props does not reference it for every *.Tests project (exit code 5)."); + Assert.True(versioned.Contains(package), $"{package} has no version in Directory.Packages.props."); + } + + foreach (string token in tokens.Where(static token => token.StartsWith("--report-", StringComparison.Ordinal) || + token.StartsWith("--hangdump", StringComparison.Ordinal) || + token.StartsWith("--crash", StringComparison.Ordinal) || + token.StartsWith("--coverage", StringComparison.Ordinal))) + { + Assert.True(OptionOwners.Keys.Any(option => token == option || token.StartsWith(option + "-", StringComparison.Ordinal)), + $"The CI test command passes {token}; add its owning extension to this table and to eng/Tests.props."); + } + } +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Toolchain/ToolchainPinTests.cs b/tests/CheatEngine.Client.Repository.Tests/Toolchain/ToolchainPinTests.cs new file mode 100644 index 0000000..56090bf --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Toolchain/ToolchainPinTests.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Toolchain; + +/// +/// The toolchain is pinned so a build is reproducible from the commit alone: the exact .NET SDK (lock files record the +/// SDK-implicit packages), the analysis level (a newer SDK band must not add CA/IDE errors) and the NuGet audit policy. +/// The MSBuild guards CHEATENGINECLIENT9030-9032 catch overrides at build time; these tests catch edits of the pins. +/// +public sealed partial class ToolchainPinTests +{ + private const string SupplyChainTargetName = "CheatEngineClientValidateSupplyChainSettings"; + + private static readonly JsonDocumentOptions JsonOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + private static readonly string[] BlockingAuditCodes = ["NU1903", "NU1904"]; + + private static readonly string[] AuditCodes = ["NU1900", "NU1901", "NU1902", "NU1903", "NU1904", "NU1905"]; + + [Fact] + public void GlobalJsonRequiresTheExactSdkWithRollForwardDisabled() + { + using JsonDocument globalJson = ReadGlobalJson(); + JsonElement sdk = globalJson.RootElement.GetProperty("sdk"); + + string version = sdk.GetProperty("version").GetString() ?? string.Empty; + Assert.Matches(SdkVersion(), version); + Assert.Equal("disable", sdk.GetProperty("rollForward").GetString()); + Assert.Equal(JsonValueKind.False, sdk.GetProperty("allowPrerelease").ValueKind); + Assert.Equal("Microsoft.Testing.Platform", + globalJson.RootElement.GetProperty("test").GetProperty("runner").GetString()); + } + + [Fact] + public void GlobalJsonErrorMessageNamesThePinnedSdkVersion() + { + using JsonDocument globalJson = ReadGlobalJson(); + JsonElement sdk = globalJson.RootElement.GetProperty("sdk"); + string version = sdk.GetProperty("version").GetString() ?? string.Empty; + + Assert.True(sdk.TryGetProperty("errorMessage", out JsonElement errorMessage), + "global.json must set sdk.errorMessage so a missing SDK fails with the install command."); + string message = errorMessage.GetString() ?? string.Empty; + Assert.Contains(version, message, StringComparison.Ordinal); + Assert.Contains($"--version {version}", message, StringComparison.Ordinal); + } + + [Fact] + public void AnalysisLevelIsPinnedToAReleaseNotLatest() + { + XDocument props = LoadXml("Directory.Build.props"); + string pin = SingleUnconditionalProperty(props, "_CheatEngineClientPinnedAnalysisLevel"); + + Assert.Matches(RecommendedAnalysisLevel(), pin); + Assert.Equal("$(_CheatEngineClientPinnedAnalysisLevel)", SingleUnconditionalProperty(props, "AnalysisLevel")); + + using JsonDocument globalJson = ReadGlobalJson(); + string sdkVersion = globalJson.RootElement.GetProperty("sdk").GetProperty("version").GetString() ?? string.Empty; + string sdkMajorMinor = string.Join('.', sdkVersion.Split('.').Take(2)); + Assert.True(pin.StartsWith(sdkMajorMinor + "-", StringComparison.Ordinal), + $"AnalysisLevel pin '{pin}' must follow the pinned SDK {sdkVersion}: raise both together."); + } + + [Fact] + public void NuGetAuditBlocksHighAndCriticalAdvisoriesInEveryBuild() + { + XDocument props = LoadXml("Directory.Build.props"); + Assert.Equal("true", SingleUnconditionalProperty(props, "NuGetAudit")); + Assert.Equal("all", SingleUnconditionalProperty(props, "NuGetAuditMode")); + Assert.Equal("low", SingleUnconditionalProperty(props, "NuGetAuditLevel")); + + XElement auditPipeline = Assert.Single(props.Descendants("WarningsAsErrors"), + static element => ((string?) element.Attribute("Condition") ?? string.Empty).Contains("AuditPipeline", + StringComparison.Ordinal)); + string auditCodes = SingleUnconditionalProperty(props, "_CheatEngineClientNuGetAuditCodes"); + Assert.Contains("$(_CheatEngineClientNuGetAuditCodes)", auditPipeline.Value, StringComparison.Ordinal); + Assert.Equal(AuditCodes, SplitCodes(auditCodes)); + + List violations = []; + foreach (string file in EnumerateMsBuildFiles()) + { + XDocument document = LoadXml(file); + foreach (XElement element in document.Descendants()) + { + if (element.Name.LocalName is not ("NoWarn" or "WarningsNotAsErrors")) + { + continue; + } + + foreach (string code in SplitCodes(element.Value)) + { + if (BlockingAuditCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) + { + violations.Add($"{file}: <{element.Name.LocalName}> lists {code}"); + } + } + } + } + + Assert.True(violations.Count == 0, + "High and critical advisories must fail every build; use NuGetAuditSuppress for a single advisory instead: " + + string.Join("; ", violations)); + } + + [Fact] + public void SupplyChainGuardsUseTheAllocatedDiagnosticIds() + { + XDocument targets = LoadXml("Directory.Build.targets"); + XElement guard = Assert.Single(targets.Root!.Elements("Target"), + static target => (string?) target.Attribute("Name") == SupplyChainTargetName); + Assert.Equal("BeforeBuild", (string?) guard.Attribute("BeforeTargets")); + + HashSet guardCodes = new(StringComparer.Ordinal); + foreach (XElement error in guard.Elements("Error")) + { + string code = (string?) error.Attribute("Code") ?? string.Empty; + Assert.Matches(ToolchainGuardCode(), code); + guardCodes.Add(code); + } + + string[] expectedCodes = ["CHEATENGINECLIENT9030", "CHEATENGINECLIENT9031", "CHEATENGINECLIENT9032"]; + Assert.Equal(expectedCodes, guardCodes.Order(StringComparer.Ordinal).ToArray()); + + foreach (XElement error in targets.Descendants("Error")) + { + string code = (string?) error.Attribute("Code") ?? string.Empty; + if (code.StartsWith("CHEATENGINECLIENT903", StringComparison.Ordinal)) + { + Assert.Same(guard, error.Parent); + } + } + } + + private static JsonDocument ReadGlobalJson() + { + return JsonDocument.Parse(File.ReadAllText(Path.Combine(RepositoryRoot.Path, "global.json")), JsonOptions); + } + + private static XDocument LoadXml(string relativePath) + { + return XDocument.Load(Path.Combine(RepositoryRoot.Path, relativePath)); + } + + private static string SingleUnconditionalProperty(XDocument document, string name) + { + XElement property = Assert.Single(document.Descendants(name), + static element => element.Attribute("Condition") is null); + return property.Value.Trim(); + } + + private static string[] SplitCodes(string value) + { + return value.Split([';', ',', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Where(static code => !code.StartsWith("$(", StringComparison.Ordinal)) + .ToArray(); + } + + private static IEnumerable EnumerateMsBuildFiles() + { + foreach (string pattern in new[] { "*.props", "*.targets", "*.csproj" }) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + yield return file; + } + } + } + + [GeneratedRegex(@"^\d+\.\d+\.\d{3}$")] + private static partial Regex SdkVersion(); + + [GeneratedRegex(@"^\d+\.\d+-recommended$")] + private static partial Regex RecommendedAnalysisLevel(); + + [GeneratedRegex("^CHEATENGINECLIENT903[0-9]$")] + private static partial Regex ToolchainGuardCode(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Workflows/SonarWorkflowTests.cs b/tests/CheatEngine.Client.Repository.Tests/Workflows/SonarWorkflowTests.cs new file mode 100644 index 0000000..d668778 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Workflows/SonarWorkflowTests.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +namespace CheatEngine.Client.Repository.Tests.Workflows; + +/// +/// Freezes the analysis scope of sonar.yml: every excluded path exists, and shipping code is excluded from the +/// coverage metric file by file, never by a folder pattern, so a new file in libs/ or src/ keeps full +/// coverage accounting unless a lot deliberately lists it. +/// +public sealed partial class SonarWorkflowTests +{ + private const string SonarWorkflow = ".github/workflows/sonar.yml"; + + /// Build output, created by the analysis build itself and never tracked. + private const string BuildOutputPattern = "artifacts/**"; + + [Fact] + public void EveryAnalysisExclusionNamesAPathThatExists() + { + string[] patterns = [.. Exclusions("sonar.exclusions"), .. Exclusions("sonar.coverage.exclusions")]; + string[] missing = + [ + .. patterns.Where(static pattern => pattern != BuildOutputPattern) + .Where(static pattern => !Exists(pattern)) + ]; + + Assert.NotEmpty(patterns); + Assert.True(missing.Length == 0, + $"{SonarWorkflow} excludes paths that do not exist; remove them: {string.Join(", ", missing)}"); + Assert.DoesNotContain("docs/**", patterns); + } + + [Fact] + public void ShippingCodeIsExcludedFromCoverageFileByFile() + { + string[] shipping = + [ + .. Exclusions("sonar.coverage.exclusions") + .Where(static pattern => pattern.StartsWith("libs/", StringComparison.Ordinal) || + pattern.StartsWith("src/", StringComparison.Ordinal)) + ]; + + Assert.NotEmpty(shipping); + Assert.All(shipping, static pattern => + { + Assert.DoesNotContain('*', pattern); + Assert.EndsWith(".cs", pattern, StringComparison.Ordinal); + }); + Assert.DoesNotContain(Exclusions("sonar.exclusions"), static pattern => + pattern.StartsWith("libs/", StringComparison.Ordinal) || pattern.StartsWith("src/", StringComparison.Ordinal)); + } + + private static string[] Exclusions(string property) + { + WorkflowStep begin = Assert.Single(WorkflowFile.Load(SonarWorkflow).Job("analyze").Steps, + static step => step.Run.Contains("dotnet-sonarscanner.exe\" begin", StringComparison.Ordinal)); + Match match = Assert.Single(ScannerProperty().Matches(begin.Run), + candidate => candidate.Groups["name"].Value == property); + return match.Groups["value"].Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static bool Exists(string pattern) + { + string path = Path.Combine(RepositoryRoot.Path, + (pattern.EndsWith("/**", StringComparison.Ordinal) ? pattern[..^3] : pattern).Replace('/', Path.DirectorySeparatorChar)); + return Directory.Exists(path) || File.Exists(path); + } + + [GeneratedRegex(@"/d:(?sonar\.[a-z.]+)=(?[^'""\r\n]+)", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ScannerProperty(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Workflows/WorkflowContractTests.cs b/tests/CheatEngine.Client.Repository.Tests/Workflows/WorkflowContractTests.cs new file mode 100644 index 0000000..2ac497a --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Workflows/WorkflowContractTests.cs @@ -0,0 +1,1087 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Workflows; + +/// +/// Freezes the CI contract shared with CheatEngine.SDK: the required check "CI / Gate" and how it is produced, the job +/// ids and names, the Sonar expectation, runner labels, timeouts, permissions, action pins, artifact names, the absence +/// of NuGet caches on release-reachable paths, locked restores, and the build-test order that tests the packages CI +/// publishes. The rules iterate the workflows that exist, so workflows added later are held to them too. They assert +/// structure, not long literal text, so the YAML can be reformatted safely. +/// +public sealed partial class WorkflowContractTests +{ + private const string CiWorkflow = ".github/workflows/ci.yml"; + private const string SonarWorkflow = ".github/workflows/sonar.yml"; + private const string MainCiWorkflow = ".github/workflows/main-ci.yml"; + private const string PullRequestCiWorkflow = ".github/workflows/pull-request-ci.yml"; + private const string PolicyWorkflow = ".github/workflows/pr-policy.yml"; + private const string ReleaseWorkflow = ".github/workflows/release.yml"; + private const string SetupAction = ".github/actions/setup-dotnet/action.yml"; + private const string SetupActionReference = "./.github/actions/setup-dotnet"; + private const string ZizmorConfig = ".github/zizmor.yml"; + + private static readonly string[] PinnedRunners = ["windows-2025", "ubuntu-24.04"]; + + /// The four workflows of the CI pipeline itself; other workflows (for example Scorecard) may omit defaults. + private static readonly string[] PipelineWorkflows = [CiWorkflow, SonarWorkflow, MainCiWorkflow, PullRequestCiWorkflow]; + + /// Workflows every job of which a release run, Sonar or CodeQL can reach: none may use a NuGet package cache. + private static readonly string[] ReleaseReachableWorkflows = + [ + CiWorkflow, SonarWorkflow, ".github/workflows/codeql.yml", ReleaseWorkflow + ]; + + /// The frozen job ids and names of ci.yml (a check is named "CI / <name>"). + private static readonly Dictionary CiJobs = new(StringComparer.Ordinal) + { + ["build-test"] = "Build and test (${{ matrix.configuration }})", + ["aot"] = "Native AOT publication probe", + ["sonar"] = "Sonar", + ["lint"] = "Lint", + ["format"] = "Format", + ["dependency-review"] = "Dependency review", + ["lock-files"] = "Lock files", + ["gate"] = "Gate" + }; + + /// Advisory ci.yml jobs outside the Gate (continue-on-error). The Client has none; keep the mechanism. + private static readonly HashSet AdvisoryJobs = new(StringComparer.Ordinal); + + /// Job ids the Client pipeline retired; they must not come back. + private static readonly string[] RetiredJobs = ["validate", "lint-workflows"]; + + /// + /// Every artifact name a workflow may upload. Names are reserved so producers and consumers cannot drift and an + /// upload never collides; adding one is a reviewed change of this list. + /// + private static readonly HashSet ReservedArtifacts = new(StringComparer.Ordinal) + { + "nuget-packages", + "coverage", + "test-results-Debug", + "test-results-Release", + "test-dumps-Debug", + "test-dumps-Release", + "release-notes", + "release-staging", + "attestation-bundles", + // Advisory governance workflows (scorecard.yml). + "scorecard-results" + }; + + /// Names the old pipeline used; reusing one would silently feed an obsolete consumer. + private static readonly string[] RetiredArtifacts = ["test-results", "sonar-coverage", "native-aot-probe"]; + + /// The canonical pin of each action used by either repository (owner/repository, commit SHA, release tag). + private static readonly Dictionary CanonicalPins = new(StringComparer.Ordinal) + { + ["actions/checkout"] = ("3d3c42e5aac5ba805825da76410c181273ba90b1", "v7.0.1"), + ["actions/upload-artifact"] = ("043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", "v7.0.1"), + ["actions/download-artifact"] = ("3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "v8.0.1"), + ["actions/setup-dotnet"] = ("a98b56852c35b8e3190ac28c8c2271da59106c68", "v6.0.0"), + ["actions/setup-java"] = ("de7274f081f381c8f8158605e0321c36c376e2e6", "v6.0.1"), + ["actions/attest"] = ("1e69f48acb82d1966a394da916b4c1698aa569d6", "v4.2.2"), + ["NuGet/login"] = ("8d196754b4036150537f80ac539e15c2f1028841", "v1.2.0"), + ["xmake-io/github-action-setup-xmake"] = ("3a1a5dddfc7fa625d9a698738334bf55655a861a", "v1.2.5"), + ["actions/dependency-review-action"] = ("a1d282b36b6f3519aa1f3fc636f609c47dddb294", "v5.0.0"), + ["zizmorcore/zizmor-action"] = ("cc914d7f3750a2d13d75c7f184a1060aa0e9d482", "v0.6.4"), + ["github/codeql-action"] = ("1c5b675653bb5c22dbe9b12b556ec555138e09fd", "v4.38.1"), + ["ossf/scorecard-action"] = ("2d1146689b8cda280b9bc96326124645441f03bc", "v2.4.4"), + ["actions/cache"] = ("55cc8345863c7cc4c66a329aec7e433d2d1c52a9", "v6.1.0") + }; + + // ---- Callers, triggers and the required check ------------------------------------------------------------------- + + [Fact] + public void CallersInvokeCiThroughJobCiNamedCi() + { + List callers = []; + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + foreach (WorkflowJob job in workflow.Jobs) + { + if (job.Uses == "./.github/workflows/ci.yml") + { + callers.Add(workflow.RelativePath); + Assert.True(job.Id == "ci" && job.Name == "CI", + $"{workflow.RelativePath} calls ci.yml from job '{job.Id}' named '{job.Name}'; the required check is 'CI / Gate', so the caller must be job 'ci' named 'CI'."); + } + } + } + + Assert.Contains(PullRequestCiWorkflow, callers); + Assert.Contains(MainCiWorkflow, callers); + if (WorkflowFile.Exists(ReleaseWorkflow)) + { + Assert.Contains(ReleaseWorkflow, callers); + } + } + + [Fact] + public void NoWorkflowUsesPullRequestTargetOrAMergeGroupTrigger() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + string[] triggers = Triggers(workflow); + Assert.False(triggers.Contains("pull_request_target"), + $"{workflow.RelativePath} uses pull_request_target, which runs untrusted code with repository secrets."); + Assert.False(triggers.Contains("merge_group"), + $"{workflow.RelativePath} listens to merge_group; there is no merge queue, and an untested event path must not produce the required check."); + } + } + + [Fact] + public void PullRequestAndPolicyWorkflowsHaveNoPathFilters() + { + foreach (string path in new[] { PullRequestCiWorkflow, PolicyWorkflow }) + { + if (!WorkflowFile.Exists(path)) + { + continue; + } + + WorkflowFile workflow = WorkflowFile.Load(path); + if (Yaml.Get(workflow.Root, "on") is YamlMappingNode on) + { + foreach (KeyValuePair trigger in on.Children) + { + YamlMappingNode? filters = trigger.Value as YamlMappingNode; + Assert.True(Yaml.Get(filters, "paths") is null && Yaml.Get(filters, "paths-ignore") is null, + $"{path} filters its '{((YamlScalarNode) trigger.Key).Value}' trigger by path; a required check that does not run on some pull requests never reports and blocks them, or lets them through unchecked."); + } + } + } + } + + [Fact] + public void MainCiHasNoConcurrencyGroup() + { + WorkflowFile workflow = WorkflowFile.Load(MainCiWorkflow); + Assert.Null(Yaml.Get(workflow.Root, "concurrency")); + Assert.Null(Yaml.Get(workflow.Job("ci").Node, "concurrency")); + string[] expectedTriggers = ["push", "workflow_dispatch"]; + Assert.Equal(expectedTriggers, Triggers(workflow).Order(StringComparer.Ordinal).ToArray()); + Assert.Equal("true", Yaml.Scalar(Yaml.Mapping(workflow.Job("ci").Node, "with"), "sonar")); + } + + [Fact] + public void PullRequestCiCancelsSupersededRunsByPullRequestNumber() + { + WorkflowFile workflow = WorkflowFile.Load(PullRequestCiWorkflow); + YamlMappingNode concurrency = Assert.IsType(Yaml.Get(workflow.Root, "concurrency")); + Assert.Contains("github.event.pull_request.number", Yaml.Scalar(concurrency, "group") ?? string.Empty, StringComparison.Ordinal); + Assert.Equal("true", Yaml.Scalar(concurrency, "cancel-in-progress")); + + YamlMappingNode pullRequest = Assert.IsType(Yaml.Get(Yaml.Mapping(workflow.Root, "on"), "pull_request")); + string[] types = Yaml.Sequence(pullRequest, "types")!.Children.Select(static type => ((YamlScalarNode) type).Value!).ToArray(); + string[] expectedTypes = ["opened", "synchronize", "reopened", "ready_for_review"]; + Assert.Equal(expectedTypes, types); + + WorkflowJob ci = workflow.Job("ci"); + Assert.Equal("!github.event.pull_request.draft", Yaml.NormalizeExpression(ci.Condition)); + Assert.Equal("true", Yaml.Scalar(Yaml.Mapping(ci.Node, "with"), "sonar")); + } + + // ---- ci.yml jobs and the Gate ------------------------------------------------------------------------------------- + + [Fact] + public void CiJobsMatchTheFrozenContractIdsAndNames() + { + WorkflowFile ci = WorkflowFile.Load(CiWorkflow); + Dictionary jobs = ci.Jobs.ToDictionary(static job => job.Id, static job => job.Name, StringComparer.Ordinal); + + Assert.Equal(CiJobs.Keys.Order(StringComparer.Ordinal), jobs.Keys.Order(StringComparer.Ordinal)); + foreach ((string id, string name) in CiJobs) + { + Assert.True(jobs[id] == name, $"ci.yml job '{id}' is named '{jobs[id]}'; the contract name is '{name}'."); + } + + foreach (string retired in RetiredJobs) + { + Assert.False(jobs.ContainsKey(retired), $"ci.yml brings back the retired job '{retired}'."); + } + + Assert.Equal("CI", Yaml.Scalar(ci.Root, "name")); + string[] expectedTriggers = ["workflow_call"]; + Assert.Equal(expectedTriggers, Triggers(ci)); + } + + [Fact] + public void GateJobIsNamedGateRunsAlwaysAndHasNoPermissions() + { + WorkflowJob gate = WorkflowFile.Load(CiWorkflow).Job("gate"); + + Assert.Equal("Gate", gate.Name); + Assert.Equal("always()", Yaml.NormalizeExpression(gate.Condition)); + YamlMappingNode permissions = Assert.IsType(Yaml.Get(gate.Node, "permissions")); + Assert.Empty(permissions.Children); + Assert.Equal("ubuntu-24.04", gate.RunsOn); + Assert.DoesNotContain(gate.Steps, static step => step.UsesAction("actions/checkout")); + + WorkflowStep check = Assert.Single(gate.Steps); + Assert.Equal("toJSON(needs)", Yaml.NormalizeExpression(check.Env("NEEDS"))); + Assert.Contains("ConvertFrom-Json", check.Run, StringComparison.Ordinal); + Assert.Contains("exit 1", check.Run, StringComparison.Ordinal); + } + + [Fact] + public void GateNeedsEveryOtherCiJobExceptTheAdvisoryAllowlist() + { + WorkflowFile ci = WorkflowFile.Load(CiWorkflow); + string[] expected = ci.Jobs.Select(static job => job.Id) + .Where(static id => id != "gate" && !AdvisoryJobs.Contains(id)) + .Order(StringComparer.Ordinal) + .ToArray(); + string[] needs = ci.Job("gate").Needs.Order(StringComparer.Ordinal).ToArray(); + + Assert.True(expected.SequenceEqual(needs), + $"gate.needs = [{string.Join(", ", needs)}] but must list every other ci.yml job: [{string.Join(", ", expected)}]. A job missing from the Gate can fail without failing 'CI / Gate'."); + + foreach (string advisory in AdvisoryJobs) + { + Assert.Equal("true", Yaml.Scalar(ci.Job(advisory).Node, "continue-on-error")); + } + } + + [Fact] + public void OnlySonarAndGateHaveJobLevelConditions() + { + foreach (WorkflowJob job in WorkflowFile.Load(CiWorkflow).Jobs) + { + if (job.Id is "sonar" or "gate") + { + Assert.False(string.IsNullOrWhiteSpace(job.Condition), $"ci.yml job '{job.Id}' must keep its condition."); + continue; + } + + Assert.True(job.Condition is null, + $"ci.yml job '{job.Id}' has a job-level condition; the Gate accepts no skip except sonar, so decide per event at step level."); + } + } + + [Fact] + public void SonarConditionEqualsTheGateSonarExpectedExpression() + { + WorkflowFile ci = WorkflowFile.Load(CiWorkflow); + string condition = Yaml.NormalizeExpression(ci.Job("sonar").Condition); + string expected = Yaml.NormalizeExpression(Assert.Single(ci.Job("gate").Steps).Env("SONAR_EXPECTED")); + + Assert.Equal(expected, condition); + Assert.StartsWith("inputs.sonar &&", condition, StringComparison.Ordinal); + Assert.Contains("github.event_name != 'merge_group'", condition, StringComparison.Ordinal); + Assert.Contains("github.actor != 'dependabot[bot]'", condition, StringComparison.Ordinal); + Assert.Contains("github.event.pull_request.head.repo.full_name == github.repository", condition, StringComparison.Ordinal); + } + + [Fact] + public void SonarWaitsForTheQualityGateOutsidePushEvents() + { + WorkflowJob sonar = WorkflowFile.Load(CiWorkflow).Job("sonar"); + Assert.Equal("./.github/workflows/sonar.yml", sonar.Uses); + Assert.Equal("github.event_name != 'push'", + Yaml.NormalizeExpression(Yaml.Scalar(Yaml.Mapping(sonar.Node, "with"), "wait-quality-gate"))); + Assert.Equal("build-test", Assert.Single(sonar.Needs)); + + string[] callerInputs = [.. Yaml.Keys(Yaml.Mapping(sonar.Node, "with"))]; + Assert.Equal("wait-quality-gate", Assert.Single(callerInputs)); + + // CI-based analysis is the only method: no opt-out input, no repository variable deciding whether Sonar runs. + WorkflowFile workflow = WorkflowFile.Load(SonarWorkflow); + YamlMappingNode? call = Yaml.Mapping(Yaml.Mapping(workflow.Root, "on"), "workflow_call"); + string[] inputs = [.. Yaml.Keys(Yaml.Mapping(call, "inputs")).Order(StringComparer.Ordinal)]; + string[] expectedInputs = ["organization", "project-key", "wait-quality-gate"]; + Assert.Equal(expectedInputs, inputs); + Assert.DoesNotContain("SONAR_CI_ENABLED", workflow.Text, StringComparison.Ordinal); + WorkflowJob analyze = workflow.Job("analyze"); + Assert.Equal("inputs.wait-quality-gate", + Yaml.NormalizeExpression(Yaml.Scalar(Yaml.Mapping(analyze.Node, "env"), "SONAR_WAIT_QUALITY_GATE"))); + Assert.Contains(analyze.Steps, static step => step.Run.Contains("sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE", StringComparison.Ordinal)); + } + + [Fact] + public void SonarRestoresLockedBeforeScannerBegin() + { + IReadOnlyList steps = WorkflowFile.Load(SonarWorkflow).Job("analyze").Steps; + WorkflowStep restore = Assert.Single(steps, static step => DotnetRestore().IsMatch(step.Run)); + WorkflowStep begin = Assert.Single(steps, static step => step.Run.Contains("dotnet-sonarscanner.exe\" begin", StringComparison.Ordinal)); + WorkflowStep build = Assert.Single(steps, static step => DotnetBuild().IsMatch(step.Run)); + + Assert.Contains("--locked-mode", restore.Run, StringComparison.Ordinal); + Assert.Contains("--configfile", restore.Run, StringComparison.Ordinal); + Assert.True(restore.Index < begin.Index, "sonar.yml must restore before scanner begin, while no credential is configured."); + Assert.True(begin.Index < build.Index); + Assert.Contains("--no-restore", build.Run, StringComparison.Ordinal); + Assert.Contains("-c Debug", build.Run, StringComparison.Ordinal); + Assert.Contains(steps, static step => step.With("name") == "coverage"); + } + + // ---- build-test --------------------------------------------------------------------------------------------------- + + [Fact] + public void ReleaseLegPacksBeforeTestingAndExportsThePackageSource() + { + WorkflowJob buildTest = WorkflowFile.Load(CiWorkflow).Job("build-test"); + IReadOnlyList steps = buildTest.Steps; + WorkflowStep build = Assert.Single(steps, static step => DotnetBuild().IsMatch(step.Run)); + WorkflowStep pack = Assert.Single(steps, static step => DotnetPack().IsMatch(step.Run)); + WorkflowStep test = Assert.Single(steps, static step => DotnetTest().IsMatch(step.Run)); + + Assert.True(build.Index < pack.Index && pack.Index < test.Index, "build-test must run Build, then Pack, then Test."); + Assert.Equal("pack", pack.Id); + Assert.Contains("'Release'", pack.Condition ?? string.Empty, StringComparison.Ordinal); + Assert.Contains("--no-build", pack.Run, StringComparison.Ordinal); + Assert.Contains("manifest.spdx.json", pack.Run, StringComparison.Ordinal); + + Assert.Equal("steps.pack.outputs.package-source", Yaml.NormalizeExpression(test.Env("PACKAGE_SOURCE"))); + Assert.Contains("CHEATENGINE_CLIENT_PACKAGE_SOURCE", test.Run, StringComparison.Ordinal); + Assert.DoesNotContain("CHEATENGINE_CLIENT_PACKAGE_SOURCE", Yaml.Keys(Yaml.Mapping(buildTest.Node, "env"))); + foreach (WorkflowStep step in steps) + { + Assert.Null(step.Env("CHEATENGINE_CLIENT_PACKAGE_SOURCE")); + } + } + + [Fact] + public void DebugLegExcludesPackageConsumptionTestsByTraitNeverBySkip() + { + WorkflowStep test = TestStep(); + IReadOnlyList tokens = Yaml.Tokens(test.Run); + + Assert.Contains("Category=PackageConsumption", TraitExclusions(tokens)); + Assert.DoesNotContain("Category=PackageConsumption", TraitExclusions(Yaml.Tokens(SharedTestOptions(test.Run)))); + Assert.Equal("on", TokenAfter(tokens, "--fail-skips")); + Assert.Equal("CheatEngine.Client.slnx", TokenAfter(tokens, "--solution")); + Assert.Contains("--no-build", tokens); + Assert.DoesNotContain("--", tokens); + Assert.DoesNotContain("--filter-class", tokens); + Assert.DoesNotContain("--filter-not-class", tokens); + } + + /// + /// The live qualification tests start a sandboxed Cheat Engine: they run only on a maintainer workstation that sets the + /// opt-in, and fail rather than skip anywhere else. CI therefore excludes them by trait in the option array both legs + /// share, never through a branch, a positive filter or a skip, and no workflow ever sets the opt-in variables. + /// + [Fact] + public void LiveQualificationTestsNeverRunInCi() + { + WorkflowStep test = TestStep(); + IReadOnlyList shared = Yaml.Tokens(SharedTestOptions(test.Run)); + Assert.Contains("Category=LiveQualification", TraitExclusions(shared)); + Assert.Equal("on", TokenAfter(shared, "--fail-skips")); + + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + Assert.DoesNotContain("CHEATENGINE_CLIENT_LIVE_QUALIFICATION", workflow.Text, StringComparison.Ordinal); + foreach (WorkflowStep step in workflow.Jobs.SelectMany(static job => job.Steps).Concat(workflow.CompositeSteps) + .Where(static step => DotnetTest().IsMatch(step.Run))) + { + IReadOnlyList tokens = Yaml.Tokens(step.Run); + Assert.True(TraitExclusions(tokens).Contains("Category=LiveQualification"), + $"{workflow.RelativePath} step '{step.Name}' runs dotnet test without --filter-not-trait Category=LiveQualification."); + Assert.DoesNotContain("--filter-trait", tokens); + Assert.DoesNotContain("--filter-query", tokens); + Assert.DoesNotContain("--filter-uid", tokens); + } + } + } + + [Fact] + public void HangDumpTimeoutIsWellBelowTheBuildTestJobTimeout() + { + WorkflowJob buildTest = WorkflowFile.Load(CiWorkflow).Job("build-test"); + IReadOnlyList tokens = Yaml.Tokens(TestStep().Run); + Assert.Contains("--hangdump", tokens); + Assert.Contains("--crashdump", tokens); + + string timeout = TokenAfter(tokens, "--hangdump-timeout"); + Match duration = HangDumpDuration().Match(timeout); + Assert.True(duration.Success, $"--hangdump-timeout '{timeout}' must use m, min, s or h."); + double minutes = double.Parse(duration.Groups["value"].Value, CultureInfo.InvariantCulture) * + duration.Groups["unit"].Value switch + { + "s" => 1.0 / 60.0, + "h" => 60.0, + _ => 1.0 + }; + int jobTimeout = int.Parse(buildTest.TimeoutMinutes!, CultureInfo.InvariantCulture); + Assert.True(minutes <= jobTimeout / 2.0, + $"The hang dump fires after {minutes} minutes without test activity; keep it at most half of build-test's {jobTimeout}-minute timeout so the dump is written and uploaded."); + } + + [Fact] + public void BuildTestNeverPromotesEveryWarningToAnError() + { + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + foreach (WorkflowStep step in workflow.Jobs.SelectMany(static job => job.Steps).Concat(workflow.CompositeSteps)) + { + Assert.False(WarnAsErrorSwitch().IsMatch(step.Run), + $"{workflow.RelativePath} step '{step.Name}' passes -warnaserror, which also promotes the NU1901/NU1902 audit warnings and overrides the NuGet audit policy; TreatWarningsAsErrors already covers compiler and analyzer warnings."); + } + } + } + + [Fact] + public void BenchmarksAreCompiledByTheSolutionBuild() + { + XDocument solution = XDocument.Load(RepositoryRoot.SolutionPath); + Assert.Contains(solution.Descendants("Project"), + static project => (string?) project.Attribute("Path") == "tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj"); + + IReadOnlyList steps = WorkflowFile.Load(CiWorkflow).Job("build-test").Steps; + WorkflowStep build = Assert.Single(steps, static step => DotnetBuild().IsMatch(step.Run)); + Assert.Contains("CheatEngine.Client.slnx", build.Run, StringComparison.Ordinal); + Assert.Contains(steps, static step => step.Run.Contains("CheatEngine.Client.Benchmarks.csproj", StringComparison.Ordinal) && + step.Run.Contains("--list", StringComparison.Ordinal)); + } + + [Fact] + public void AotJobPublishesAndRunsTheAotProbe() + { + WorkflowJob aot = WorkflowFile.Load(CiWorkflow).Job("aot"); + Assert.Equal("windows-2025", aot.RunsOn); + Assert.Empty(aot.Needs); + + WorkflowStep setup = Assert.Single(aot.Steps, static step => step.Uses == SetupActionReference); + Assert.Contains("tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj", setup.With("restore") ?? string.Empty, StringComparison.Ordinal); + + WorkflowStep publish = Assert.Single(aot.Steps, static step => DotnetPublish().IsMatch(step.Run)); + Assert.Contains("tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj", publish.Run, StringComparison.Ordinal); + Assert.Contains("--no-restore", publish.Run, StringComparison.Ordinal); + Assert.DoesNotContain("--runtime", publish.Run, StringComparison.Ordinal); + Assert.Contains("CheatEngine.Client.AotProbe.exe", publish.Run, StringComparison.Ordinal); + Assert.Contains("$LASTEXITCODE", publish.Run, StringComparison.Ordinal); + + foreach (WorkflowStep upload in aot.Steps.Where(static step => step.UsesAction("actions/upload-artifact"))) + { + Assert.DoesNotContain("aot-probe", upload.With("path") ?? string.Empty, StringComparison.Ordinal); + } + } + + // ---- lint, format, dependency review, lock files ------------------------------------------------------------------ + + [Fact] + public void LintJobRunsActionlintAndZizmorOnEveryEvent() + { + WorkflowJob lint = WorkflowFile.Load(CiWorkflow).Job("lint"); + Assert.Null(lint.Condition); + Assert.Equal("ubuntu-24.04", lint.RunsOn); + + WorkflowStep checkout = Assert.Single(lint.Steps, static step => step.UsesAction("actions/checkout")); + Assert.Null(checkout.With("sparse-checkout")); + + Assert.Contains(lint.Steps, static step => step.Run.Contains("actionlint", StringComparison.Ordinal) && + step.Run.Contains("Get-FileHash", StringComparison.Ordinal)); + WorkflowStep zizmor = Assert.Single(lint.Steps, static step => step.UsesAction("zizmorcore/zizmor-action")); + Assert.Equal("false", zizmor.With("online-audits")); + Assert.Equal("false", zizmor.With("advanced-security")); + Assert.Equal(ZizmorConfig, zizmor.With("config")); + foreach (WorkflowStep step in lint.Steps) + { + Assert.Null(step.Condition); + } + } + + [Fact] + public void ZizmorAndActionlintArePinnedByVersionAndChecksum() + { + WorkflowJob lint = WorkflowFile.Load(CiWorkflow).Job("lint"); + YamlMappingNode? env = Yaml.Mapping(lint.Node, "env"); + Assert.Matches(ThreePartVersion(), Yaml.Scalar(env, "ACTIONLINT_VERSION") ?? string.Empty); + Assert.Matches(Sha256Hex(), Yaml.Scalar(env, "ACTIONLINT_SHA256") ?? string.Empty); + + WorkflowStep zizmor = Assert.Single(lint.Steps, static step => step.UsesAction("zizmorcore/zizmor-action")); + Assert.Matches(ThreePartVersion(), zizmor.With("version") ?? "latest"); + } + + [Fact] + public void EveryZizmorExceptionCarriesAJustificationComment() + { + string[] lines = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, ZizmorConfig)); + int rules = 0; + for (int index = 0; index < lines.Length; index++) + { + if (!RuleEntry().IsMatch(lines[index])) + { + continue; + } + + rules++; + int previous = index - 1; + while (previous >= 0 && lines[previous].Trim().Length == 0) + { + previous--; + } + + Assert.True(previous >= 0 && lines[previous].TrimStart().StartsWith('#'), + $"{ZizmorConfig}:{index + 1} '{lines[index].Trim()}' has no justification comment above it."); + } + + Assert.True(rules > 0, $"{ZizmorConfig} declares no rule."); + + WorkflowFile config = WorkflowFile.Load(ZizmorConfig); + foreach (KeyValuePair rule in Yaml.Mapping(config.Root, "rules")!.Children) + { + foreach (YamlNode entry in Yaml.Sequence((YamlMappingNode) rule.Value, "ignore")?.Children ?? []) + { + string[] parts = ((YamlScalarNode) entry).Value!.Split(':'); + string? file = WorkflowFile.WorkflowsAndActions().Select(static workflow => workflow.RelativePath) + .Concat([".github/dependabot.yml"]) + .FirstOrDefault(path => Path.GetFileName(path) == parts[0]); + Assert.True(file is not null && WorkflowFile.Exists(file), + $"{ZizmorConfig} ignores '{parts[0]}', which is not a file under .github; remove the stale entry."); + if (parts.Length > 1) + { + string[] target = File.ReadAllLines(Path.Combine(RepositoryRoot.Path, file!)); + int line = int.Parse(parts[1], CultureInfo.InvariantCulture); + Assert.True(line <= target.Length, $"{ZizmorConfig} ignores {parts[0]}:{line}, past the end of the file."); + if (((YamlScalarNode) rule.Key).Value == "github-env") + { + string window = string.Join('\n', target.Skip(line - 1).Take(4)); + Assert.True(window.Contains("run:", StringComparison.Ordinal) && window.Contains("GITHUB_ENV", StringComparison.Ordinal), + $"{ZizmorConfig} ignores github-env at {parts[0]}:{line}, which is no longer the constant GITHUB_ENV write; update the line."); + } + } + } + } + } + + [Fact] + public void FormatJobVerifiesWhitespaceWithoutRestore() + { + WorkflowJob format = WorkflowFile.Load(CiWorkflow).Job("format"); + Assert.Null(format.Condition); + Assert.Equal("ubuntu-24.04", format.RunsOn); + + WorkflowStep setup = Assert.Single(format.Steps, static step => step.Uses == SetupActionReference); + Assert.Null(setup.With("restore")); + + WorkflowStep verify = Assert.Single(format.Steps, static step => step.Run.Contains("dotnet format", StringComparison.Ordinal)); + IReadOnlyList tokens = Yaml.Tokens(verify.Run); + Assert.Equal("whitespace", TokenAfter(tokens, "format")); + Assert.Contains("--folder", tokens); + Assert.Contains("--verify-no-changes", tokens); + Assert.DoesNotContain(format.Steps, static step => DotnetRestore().IsMatch(step.Run)); + } + + [Fact] + public void DependencyReviewJobAlwaysRunsAndReviewsOnlyPullRequests() + { + WorkflowJob review = WorkflowFile.Load(CiWorkflow).Job("dependency-review"); + Assert.Null(review.Condition); + Assert.Null(Yaml.Get(review.Node, "permissions")); + + WorkflowStep action = Assert.Single(review.Steps, static step => step.UsesAction("actions/dependency-review-action")); + Assert.Equal("github.event_name == 'pull_request'", Yaml.NormalizeExpression(action.Condition)); + Assert.Equal("./.github/dependency-review-config.yml", action.With("config-file")); + Assert.Equal("never", action.With("comment-summary-in-pr")); + Assert.True(WorkflowFile.Exists(".github/dependency-review-config.yml")); + + Assert.Contains(review.Steps, static step => Yaml.NormalizeExpression(step.Condition) == "github.event_name != 'pull_request'" && + step.Run.Contains("::notice", StringComparison.Ordinal)); + } + + [Fact] + public void LockFileJobRunsTheVerificationScriptOnWindows() + { + WorkflowJob lockFiles = WorkflowFile.Load(CiWorkflow).Job("lock-files"); + Assert.Equal("windows-2025", lockFiles.RunsOn); + Assert.Null(lockFiles.Condition); + + WorkflowStep setup = Assert.Single(lockFiles.Steps, static step => step.Uses == SetupActionReference); + Assert.Null(setup.With("restore")); + Assert.Contains(lockFiles.Steps, static step => LockedSolutionRestore().IsMatch(step.Run)); + } + + // ---- Setup, caches and restores ------------------------------------------------------------------------------------ + + [Fact] + public void CompositeSetupRestoresInLockedModeAndDefaultsCacheToFalse() + { + WorkflowFile action = WorkflowFile.Load(SetupAction); + YamlMappingNode inputs = Yaml.Mapping(action.Root, "inputs")!; + Assert.Equal("false", Yaml.Scalar(Yaml.Mapping(inputs, "cache"), "default")); + Assert.Equal(string.Empty, Yaml.Scalar(Yaml.Mapping(inputs, "restore"), "default")); + + IReadOnlyList steps = action.CompositeSteps; + WorkflowStep install = Assert.Single(steps, static step => step.UsesAction("actions/setup-dotnet")); + Assert.Equal("global.json", install.With("global-json-file")); + Assert.Equal("inputs.cache", Yaml.NormalizeExpression(install.With("cache"))); + + WorkflowStep restore = Assert.Single(steps, static step => DotnetRestore().IsMatch(step.Run)); + Assert.Contains("--locked-mode", restore.Run, StringComparison.Ordinal); + string restoreLine = Assert.Single(restore.Run.Split('\n'), static line => TargetRestore().IsMatch(line)); + Assert.DoesNotContain("--force-evaluate", restoreLine, StringComparison.Ordinal); + Assert.Contains("$LASTEXITCODE", restore.Run, StringComparison.Ordinal); + Assert.Contains("dotnet restore --force-evaluate", restore.Run, StringComparison.Ordinal); + Assert.Equal("inputs.restore", Yaml.NormalizeExpression(restore.Env("RESTORE_TARGETS"))); + Assert.DoesNotContain("${{", restore.Run, StringComparison.Ordinal); + } + + [Fact] + public void EveryDotnetJobUsesTheCompositeSetupAction() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + foreach (WorkflowJob job in workflow.Jobs) + { + WorkflowStep? firstDotnet = job.Steps.FirstOrDefault(static step => RunsDotnet(step.Run)); + if (firstDotnet is null) + { + continue; + } + + WorkflowStep? setup = job.Steps.FirstOrDefault(static step => step.Uses == SetupActionReference); + Assert.True(setup is not null && setup.Index < firstDotnet.Index, + $"{workflow.RelativePath} job '{job.Id}' runs dotnet without {SetupActionReference} first; global.json requires an exact SDK that runner images do not ship."); + Assert.DoesNotContain(job.Steps, static step => step.UsesAction("actions/setup-dotnet")); + } + } + } + + [Fact] + public void ReleaseReachableWorkflowsNeverEnableAPackageCache() + { + foreach (string path in ReleaseReachableWorkflows.Where(WorkflowFile.Exists)) + { + WorkflowFile workflow = WorkflowFile.Load(path); + foreach (WorkflowJob job in workflow.Jobs) + { + foreach (WorkflowStep step in job.Steps) + { + if (step.Uses == SetupActionReference || step.UsesAction("actions/setup-dotnet")) + { + string cache = step.With("cache") ?? "false"; + Assert.True(cache == "false", + $"{path} job '{job.Id}' enables a NuGet cache (cache: {cache}); a release, Sonar or CodeQL run must never restore packages another run could have written."); + } + + if (!step.UsesAction("actions/cache")) + { + continue; + } + + // The single documented exception: Sonar analyzer plugins, restored on any event, saved from main only. + Assert.True(path == SonarWorkflow, $"{path} job '{job.Id}' uses actions/cache."); + Assert.Contains("sonar-user-home/cache", step.With("path") ?? string.Empty, StringComparison.Ordinal); + if (step.Uses!.StartsWith("actions/cache/save@", StringComparison.Ordinal)) + { + Assert.Contains("github.ref == 'refs/heads/main'", Yaml.NormalizeExpression(step.Condition), StringComparison.Ordinal); + } + else + { + Assert.StartsWith("actions/cache/restore@", step.Uses, StringComparison.Ordinal); + } + } + } + } + } + + [Fact] + public void NoWorkflowReferencesTheLocalQualificationRunner() + { + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + Assert.DoesNotContain("eng/qualification", workflow.Text, StringComparison.Ordinal); + } + } + + [Fact] + public void NoWorkflowReadsRepositoryVariables() + { + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + Assert.False(RepositoryVariable().IsMatch(workflow.Text), + $"{workflow.RelativePath} reads a repository variable (vars.*); a setting that is not in the commit cannot be reviewed or reproduced."); + } + } + + // ---- Hygiene: runners, timeouts, permissions, pins, checkout, history --------------------------------------------- + + [Fact] + public void EveryJobHasATimeoutAndAPinnedRunnerLabel() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + foreach (WorkflowJob job in workflow.Jobs) + { + if (job.Uses is not null) + { + // GitHub rejects runs-on and timeout-minutes on a reusable-workflow call; the callee's jobs are checked. + Assert.Null(job.RunsOn); + Assert.Null(job.TimeoutMinutes); + continue; + } + + Assert.True(job.RunsOn is not null && PinnedRunners.Contains(job.RunsOn), + $"{workflow.RelativePath} job '{job.Id}' runs on '{job.RunsOn}'; use one of {string.Join(", ", PinnedRunners)} (never -latest, which moves without a commit)."); + Assert.True(int.TryParse(job.TimeoutMinutes, out int minutes) && minutes > 0, + $"{workflow.RelativePath} job '{job.Id}' has no timeout-minutes."); + } + } + } + + [Fact] + public void WorkflowsGrantOnlyReadPermissionsAtTheTopLevel() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + YamlNode? permissions = Yaml.Get(workflow.Root, "permissions"); + Assert.True(permissions is not null, + $"{workflow.RelativePath} has no top-level permissions; the default token would be broader than needed."); + if (permissions is YamlMappingNode mapping) + { + foreach (KeyValuePair permission in mapping.Children) + { + string level = ((YamlScalarNode) permission.Value).Value!; + Assert.True(level is "read" or "none", + $"{workflow.RelativePath} grants '{((YamlScalarNode) permission.Key).Value}: {level}' at the top level; elevate per job only."); + } + } + else + { + Assert.Equal("read-all", ((YamlScalarNode) permissions!).Value); + } + } + + foreach (string path in PipelineWorkflows) + { + YamlMappingNode permissions = Assert.IsType(Yaml.Get(WorkflowFile.Load(path).Root, "permissions")); + Assert.Equal("read", Yaml.Scalar(permissions, "contents")); + Assert.Single(permissions.Children); + } + + foreach (WorkflowJob job in WorkflowFile.Load(CiWorkflow).Jobs) + { + if (job.Id != "gate") + { + Assert.True(Yaml.Get(job.Node, "permissions") is null, $"ci.yml job '{job.Id}' changes permissions; no ci.yml job elevates."); + } + } + } + + [Fact] + public void DefaultShellIsPwshInTheCiWorkflows() + { + foreach (string path in PipelineWorkflows) + { + YamlMappingNode? run = Yaml.Mapping(Yaml.Mapping(WorkflowFile.Load(path).Root, "defaults"), "run"); + Assert.True(Yaml.Scalar(run, "shell") == "pwsh", $"{path} must set defaults.run.shell: pwsh."); + } + } + + [Fact] + public void EveryRemoteActionIsPinnedToAFullShaWithAVersionComment() + { + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + foreach ((int line, string reference, string? version) in UsesLines(workflow)) + { + if (reference.StartsWith("./", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(PinnedReference().IsMatch(reference) && version is not null && Version().IsMatch(version), + $"{workflow.RelativePath}:{line} uses '{reference}'; pin every action to a full commit SHA followed by '# vX.Y.Z'."); + } + } + } + + [Fact] + public void ActionsUseOnePinEverywhereAndMatchTheCanonicalTable() + { + Dictionary seen = new(StringComparer.Ordinal); + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + foreach ((int line, string reference, string? version) in UsesLines(workflow)) + { + Match pinned = PinnedReference().Match(reference); + if (!pinned.Success) + { + continue; + } + + string action = pinned.Groups["action"].Value; + string sha = pinned.Groups["sha"].Value; + string where = $"{workflow.RelativePath}:{line}"; + if (CanonicalPins.TryGetValue(action, out (string Sha, string Version) canonical)) + { + Assert.True(sha == canonical.Sha && version == canonical.Version, + $"{where} pins {action} to {sha} {version}; the canonical pin shared with CheatEngine.SDK is {canonical.Sha} # {canonical.Version}."); + } + + if (seen.TryGetValue(action, out (string Sha, string Version, string Where) first)) + { + Assert.True(first.Sha == sha && first.Version == version, + $"{where} pins {action} to {sha}, but {first.Where} pins it to {first.Sha}; use one pin everywhere."); + } + else + { + seen[action] = (sha, version ?? string.Empty, where); + } + } + } + } + + [Fact] + public void EveryCheckoutDisablesCredentialPersistence() + { + foreach (WorkflowFile workflow in WorkflowFile.WorkflowsAndActions()) + { + foreach (WorkflowStep step in workflow.Jobs.SelectMany(static job => job.Steps).Concat(workflow.CompositeSteps)) + { + if (step.UsesAction("actions/checkout")) + { + Assert.True(step.With("persist-credentials") == "false", + $"{workflow.RelativePath} step '{step.Name}' checks out without persist-credentials: false; the token must not stay in .git/config for later steps."); + } + } + } + } + + [Fact] + public void JobsThatPackTestOrPublishFetchFullHistory() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + foreach (WorkflowJob job in workflow.Jobs) + { + WorkflowStep[] versioned = job.Steps.Where(static step => + VersionedDotnetCommand().IsMatch(step.Run) || + step.Run.Contains("dotnet-sonarscanner", StringComparison.Ordinal)).ToArray(); + // A job that never needs the package version may opt out explicitly with MinVerSkip instead. + if (versioned.Length == 0 || versioned.All(static step => step.Run.Contains("MinVerSkip=true", StringComparison.Ordinal))) + { + continue; + } + + WorkflowStep checkout = Assert.Single(job.Steps, static step => step.UsesAction("actions/checkout")); + Assert.True(checkout.With("fetch-depth") == "0", + $"{workflow.RelativePath} job '{job.Id}' builds or packs without fetch-depth: 0; a shallow clone makes the package version fall back silently."); + } + } + + Assert.Equal("0", Assert.Single(WorkflowFile.Load(SonarWorkflow).Job("analyze").Steps, + static step => step.UsesAction("actions/checkout")).With("fetch-depth")); + } + + // ---- Artifacts -------------------------------------------------------------------------------------------------------- + + [Fact] + public void EveryUploadedArtifactNameIsReserved() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + HashSet names = new(StringComparer.Ordinal); + foreach (WorkflowJob job in workflow.Jobs) + { + foreach (WorkflowStep step in job.Steps.Where(static step => step.UsesAction("actions/upload-artifact"))) + { + string? name = step.With("name"); + Assert.True(name is not null, $"{workflow.RelativePath} job '{job.Id}' uploads an artifact without a name."); + foreach (string expanded in job.ExpandMatrix(name!)) + { + Assert.DoesNotContain(expanded, RetiredArtifacts); + Assert.True(ReservedArtifacts.Contains(expanded) || BinlogArtifact().IsMatch(expanded), + $"{workflow.RelativePath} job '{job.Id}' uploads '{expanded}', which is not a reserved artifact name."); + Assert.True(names.Add(expanded), $"{workflow.RelativePath} uploads '{expanded}' twice in one run."); + } + } + } + } + } + + [Fact] + public void BinlogsAreUploadedOnlyOnFailureAndNeverFromSonarOrRelease() + { + foreach (WorkflowFile workflow in WorkflowFile.Workflows()) + { + foreach (WorkflowJob job in workflow.Jobs) + { + foreach (WorkflowStep step in job.Steps.Where(static step => step.UsesAction("actions/upload-artifact"))) + { + bool binlogs = (step.With("name") ?? string.Empty).StartsWith("binlogs-", StringComparison.Ordinal) || + (step.With("path") ?? string.Empty).Contains(".binlog", StringComparison.Ordinal); + if (binlogs) + { + Assert.True(Yaml.NormalizeExpression(step.Condition) == "failure()", + $"{workflow.RelativePath} job '{job.Id}' uploads binary logs without if: failure(); they capture environment variables."); + } + } + } + } + + foreach (string path in new[] { SonarWorkflow, ReleaseWorkflow }.Where(WorkflowFile.Exists)) + { + string text = WorkflowFile.Load(path).Text; + Assert.False(BinaryLog().IsMatch(text), + $"{path} must not write or upload binary logs: they capture the environment of a credentialed job."); + } + } + + private static WorkflowStep TestStep() + { + return Assert.Single(WorkflowFile.Load(CiWorkflow).Job("build-test").Steps, + static step => DotnetTest().IsMatch(step.Run)); + } + + /// Every value passed to --filter-not-trait, in order; the option may be repeated. + private static List TraitExclusions(IReadOnlyList tokens) + { + List values = []; + for (int index = 0; index < tokens.Count - 1; index++) + { + if (tokens[index] == "--filter-not-trait") + { + values.Add(tokens[index + 1]); + } + } + + return values; + } + + /// The literal of the $options = @( ... ) array the Debug and Release legs both pass to dotnet test. + private static string SharedTestOptions(string run) + { + Match options = SharedOptionArray().Match(run); + Assert.True(options.Success, "The Test step no longer declares its shared option array as $options = @( ... )."); + return options.Groups["body"].Value; + } + + private static string TokenAfter(IReadOnlyList tokens, string option) + { + for (int index = 0; index < tokens.Count - 1; index++) + { + if (tokens[index] == option) + { + return tokens[index + 1]; + } + } + + throw new Xunit.Sdk.XunitException($"The command does not pass {option} with a value."); + } + + private static string[] Triggers(WorkflowFile workflow) + { + return Yaml.Get(workflow.Root, "on") switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => sequence.Children.Select(static item => ((YamlScalarNode) item).Value!).ToArray(), + YamlMappingNode mapping => Yaml.Keys(mapping).ToArray(), + _ => [] + }; + } + + /// True when a script runs dotnet directly or through a repository script that does. + private static bool RunsDotnet(string run) + { + if (DotnetInvocation().IsMatch(run)) + { + return true; + } + + foreach (Match script in EngScript().Matches(run)) + { + string path = Path.Combine(RepositoryRoot.Path, script.Value); + if (File.Exists(path) && ScriptDotnetInvocation().IsMatch(File.ReadAllText(path))) + { + return true; + } + } + + return false; + } + + private static IEnumerable<(int Line, string Reference, string? Version)> UsesLines(WorkflowFile workflow) + { + string[] lines = workflow.Text.Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + Match uses = UsesLine().Match(lines[index].TrimEnd('\r')); + if (uses.Success) + { + string? version = uses.Groups["comment"].Success ? uses.Groups["comment"].Value.Trim() : null; + yield return (index + 1, uses.Groups["reference"].Value, version); + } + } + } + + [GeneratedRegex(@"^\s*(-\s+)?uses:\s*(?[^\s#]+)\s*(#\s*(?.*))?$")] + private static partial Regex UsesLine(); + + [GeneratedRegex(@"^(?[A-Za-z0-9-]+/[A-Za-z0-9._-]+)(/[A-Za-z0-9._/-]+)?@(?[0-9a-f]{40})$")] + private static partial Regex PinnedReference(); + + [GeneratedRegex(@"^v\d+\.\d+\.\d+$")] + private static partial Regex Version(); + + [GeneratedRegex(@"^ [a-z][a-z0-9-]*:\s*$")] + private static partial Regex RuleEntry(); + + /// Binary logs: binlogs-<job> or binlogs-<job>-<configuration>. + [GeneratedRegex("^binlogs-[a-z0-9-]+?(-(Debug|Release))?$")] + private static partial Regex BinlogArtifact(); + + [GeneratedRegex(@"\bdotnet restore\b")] + private static partial Regex DotnetRestore(); + + [GeneratedRegex(@"\bdotnet build\b")] + private static partial Regex DotnetBuild(); + + [GeneratedRegex(@"\bdotnet pack\b")] + private static partial Regex DotnetPack(); + + [GeneratedRegex(@"\bdotnet test\b")] + private static partial Regex DotnetTest(); + + [GeneratedRegex(@"\bdotnet publish\b")] + private static partial Regex DotnetPublish(); + + [GeneratedRegex(@"\bdotnet (build|pack|test|publish)\b")] + private static partial Regex VersionedDotnetCommand(); + + [GeneratedRegex(@"\bdotnet restore CheatEngine\.Client\.slnx --locked-mode\b")] + private static partial Regex LockedSolutionRestore(); + + [GeneratedRegex(@"\bdotnet restore \$target\b")] + private static partial Regex TargetRestore(); + + [GeneratedRegex(@"^(?\d+(\.\d+)?)(?m|min|s|h)$")] + private static partial Regex HangDumpDuration(); + + [GeneratedRegex(@"(--|-|/)warnaserror\b", RegexOptions.IgnoreCase)] + private static partial Regex WarnAsErrorSwitch(); + + [GeneratedRegex(@"^\d+\.\d+\.\d+$")] + private static partial Regex ThreePartVersion(); + + [GeneratedRegex("^[0-9a-f]{64}$")] + private static partial Regex Sha256Hex(); + + [GeneratedRegex(@"\bvars\.")] + private static partial Regex RepositoryVariable(); + + [GeneratedRegex(@"(-bl\b|-bl:|/bl\b|\.binlog|binlogs-)")] + private static partial Regex BinaryLog(); + + [GeneratedRegex(@"^\s*\$options\s*=\s*@\((?.*?)^\s*\)\s*$", RegexOptions.Multiline | RegexOptions.Singleline, + 1000)] + private static partial Regex SharedOptionArray(); + + [GeneratedRegex(@"(^|[\s;&(])dotnet\s", RegexOptions.Multiline)] + private static partial Regex DotnetInvocation(); + + [GeneratedRegex(@"eng/[\w./-]+\.ps1")] + private static partial Regex EngScript(); + + [GeneratedRegex(@"&\s*dotnet\b|^\s*dotnet\s", RegexOptions.Multiline)] + private static partial Regex ScriptDotnetInvocation(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/Workflows/WorkflowFile.cs b/tests/CheatEngine.Client.Repository.Tests/Workflows/WorkflowFile.cs new file mode 100644 index 0000000..d69613c --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/Workflows/WorkflowFile.cs @@ -0,0 +1,344 @@ +using System.Text.RegularExpressions; + +using CheatEngine.Client.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.Client.Repository.Tests.Workflows; + +/// +/// A GitHub Actions workflow or composite action metadata file: its raw text (comments carry the pin versions and the +/// justifications, which the YAML model drops) and its YAML representation. The representation model keeps every +/// scalar as text, so the on key stays a plain key and true stays the string it was written as. +/// +internal sealed class WorkflowFile +{ + private const string WorkflowFolder = ".github/workflows"; + private const string ActionFolder = ".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. + public string Text + { + get; + } + + /// The root mapping. + public YamlMappingNode Root + { + get; + } + + /// The jobs of a workflow; empty for composite action metadata. + public IReadOnlyList Jobs + { + get + { + if (Yaml.Mapping(Root, "jobs") is not YamlMappingNode jobs) + { + return []; + } + + List result = []; + foreach (KeyValuePair job in jobs.Children) + { + result.Add(new WorkflowJob(this, ((YamlScalarNode) job.Key).Value!, (YamlMappingNode) job.Value)); + } + + return result; + } + } + + /// The steps of a composite action (runs.steps); empty for workflows. + public IReadOnlyList CompositeSteps => + Yaml.Mapping(Root, "runs") is YamlMappingNode runs ? WorkflowStep.From(Yaml.Sequence(runs, "steps")) : []; + + /// Loads a file by repository-relative path. + public static WorkflowFile Load(string relativePath) + { + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, relativePath)); + YamlStream stream = []; + stream.Load(new StringReader(text)); + YamlMappingNode root = stream.Documents[0].RootNode as YamlMappingNode + ?? throw new InvalidOperationException($"{relativePath} is not a YAML mapping."); + return new WorkflowFile(relativePath.Replace('\\', '/'), text, root); + } + + /// True when the repository-relative file exists. + public static bool Exists(string relativePath) + { + return File.Exists(Path.Combine(RepositoryRoot.Path, relativePath)); + } + + /// Every workflow that exists today, so the rules also cover workflows added later. + public static IReadOnlyList Workflows() + { + return EnumerateYaml(WorkflowFolder, SearchOption.TopDirectoryOnly); + } + + /// Every local composite action metadata file (.github/actions/**/action.yml). + public static IReadOnlyList Actions() + { + return EnumerateYaml(ActionFolder, SearchOption.AllDirectories) + .Where(static file => file.FileName is "action.yml" or "action.yaml") + .ToArray(); + } + + /// Workflows and action metadata together. + public static IReadOnlyList WorkflowsAndActions() + { + return [.. Workflows(), .. Actions()]; + } + + /// The job with the given id; fails the test when it does not exist. + public WorkflowJob Job(string id) + { + return Jobs.SingleOrDefault(job => job.Id == id) + ?? throw new Xunit.Sdk.XunitException($"{RelativePath} has no job '{id}'."); + } + + private static WorkflowFile[] EnumerateYaml(string folder, SearchOption option) + { + string path = Path.Combine(RepositoryRoot.Path, folder); + if (!Directory.Exists(path)) + { + return []; + } + + return Directory.EnumerateFiles(path, "*.*", option) + .Where(static file => file.EndsWith(".yml", StringComparison.Ordinal) || + file.EndsWith(".yaml", StringComparison.Ordinal)) + .Select(static file => RepositoryRoot.ToRelative(file)) + .Order(StringComparer.Ordinal) + .Select(Load) + .ToArray(); + } +} + +/// A job of a workflow. +internal sealed class WorkflowJob(WorkflowFile file, string id, YamlMappingNode node) +{ + /// The file that declares the job. + public WorkflowFile File { get; } = file; + + /// The job id (its key under jobs). + public string Id { get; } = id; + + /// The job mapping. + public YamlMappingNode Node { get; } = node; + + /// The display name. + public string? Name => Yaml.Scalar(Node, "name"); + + /// The job-level condition, if any. + public string? Condition => Yaml.Scalar(Node, "if"); + + /// The reusable workflow a caller job invokes, if any. + public string? Uses => Yaml.Scalar(Node, "uses"); + + /// The runner label. + public string? RunsOn => Yaml.Scalar(Node, "runs-on"); + + /// The job timeout. + public string? TimeoutMinutes => Yaml.Scalar(Node, "timeout-minutes"); + + /// The steps; empty for a reusable-workflow caller. + public IReadOnlyList Steps => WorkflowStep.From(Yaml.Sequence(Node, "steps")); + + /// The job ids in needs, whether written as a scalar or a sequence. + public IReadOnlyList Needs => Yaml.Get(Node, "needs") switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => sequence.Children.Select(static item => ((YamlScalarNode) item).Value!).ToArray(), + _ => [] + }; + + /// The values of each matrix dimension (strategy.matrix.<name>). + public IReadOnlyDictionary Matrix + { + get + { + Dictionary matrix = new(StringComparer.Ordinal); + if (Yaml.Mapping(Node, "strategy") is YamlMappingNode strategy && + Yaml.Mapping(strategy, "matrix") is YamlMappingNode dimensions) + { + foreach (KeyValuePair dimension in dimensions.Children) + { + if (dimension.Value is YamlSequenceNode values) + { + matrix[((YamlScalarNode) dimension.Key).Value!] = + values.Children.Select(static value => ((YamlScalarNode) value).Value!).ToArray(); + } + } + } + + return matrix; + } + } + + /// + /// Expands every ${{ matrix.<name> }} of a value over the job's matrix, so reserved names such as + /// test-results-${{ matrix.configuration }} are checked for each leg. + /// + public IReadOnlyList ExpandMatrix(string value) + { + List results = [value]; + foreach ((string name, string[] values) in Matrix) + { + Regex placeholder = new(@"\$\{\{\s*matrix\." + Regex.Escape(name) + @"\s*\}\}"); + results = results.SelectMany(result => placeholder.IsMatch(result) + ? values.Select(matrixValue => placeholder.Replace(result, matrixValue)) + : new[] { result }) + .ToList(); + } + + return results; + } +} + +/// A step of a job or of a composite action. +internal sealed class WorkflowStep(int index, YamlMappingNode node) +{ + /// The position of the step in its job. + public int Index { get; } = index; + + /// The step mapping. + public YamlMappingNode Node { get; } = node; + + /// The display name. + public string? Name => Yaml.Scalar(Node, "name"); + + /// The step id. + public string? Id => Yaml.Scalar(Node, "id"); + + /// The action reference. + public string? Uses => Yaml.Scalar(Node, "uses"); + + /// The script. + public string Run => Yaml.Scalar(Node, "run") ?? string.Empty; + + /// The step condition, if any. + public string? Condition => Yaml.Scalar(Node, "if"); + + /// True when the step uses the given action (any version, any sub-path). + public bool UsesAction(string ownerAndRepository) + { + return Uses is not null && Uses.StartsWith(ownerAndRepository, StringComparison.Ordinal) && + (Uses.Length == ownerAndRepository.Length || Uses[ownerAndRepository.Length] is '@' or '/'); + } + + /// An input of with. + public string? With(string key) + { + return Yaml.Mapping(Node, "with") is YamlMappingNode with ? Yaml.Scalar(with, key) : null; + } + + /// A variable of the step env. + public string? Env(string key) + { + return Yaml.Mapping(Node, "env") is YamlMappingNode env ? Yaml.Scalar(env, key) : null; + } + + /// Wraps the items of a steps sequence. + public static IReadOnlyList From(YamlSequenceNode? steps) + { + return steps is null + ? [] + : steps.Children.Select(static (step, index) => new WorkflowStep(index, (YamlMappingNode) step)).ToArray(); + } +} + +/// Small accessors over the YamlDotNet representation model. +internal static partial class Yaml +{ + /// The value of a key, or null. + public static YamlNode? Get(YamlMappingNode? mapping, string key) + { + return mapping is not null && mapping.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) + ? value + : null; + } + + /// A scalar value, or null. + public static string? Scalar(YamlMappingNode? mapping, string key) + { + return (Get(mapping, key) as YamlScalarNode)?.Value; + } + + /// A mapping value, or null. + public static YamlMappingNode? Mapping(YamlMappingNode? mapping, string key) + { + return Get(mapping, key) as YamlMappingNode; + } + + /// A sequence value, or null. + public static YamlSequenceNode? Sequence(YamlMappingNode? mapping, string key) + { + return Get(mapping, key) as YamlSequenceNode; + } + + /// The keys of a mapping. + public static IEnumerable Keys(YamlMappingNode? mapping) + { + return mapping is null ? [] : mapping.Children.Keys.Select(static key => ((YamlScalarNode) key).Value!); + } + + /// + /// An expression as GitHub evaluates it, for textual comparison: the optional ${{ }} wrapper removed and every + /// run of whitespace (including the line breaks of a folded scalar) collapsed to one space. + /// + public static string NormalizeExpression(string? expression) + { + string text = (expression ?? string.Empty).Trim(); + Match wrapped = WrappedExpression().Match(text); + if (wrapped.Success) + { + text = wrapped.Groups["body"].Value; + } + + return Whitespace().Replace(text, " ").Trim(); + } + + /// + /// The command-line tokens of a PowerShell script: quoted strings unquoted, separators (commas, parentheses, @() + /// dropped, so '--hangdump-timeout', '15m' and --hangdump-timeout 15m read the same. + /// + public static IReadOnlyList Tokens(string script) + { + List tokens = []; + foreach (Match match in Token().Matches(script)) + { + string token = match.Groups["quoted"].Success ? match.Groups["quoted"].Value : match.Groups["bare"].Value; + if (token.Length > 0) + { + tokens.Add(token); + } + } + + return tokens; + } + + [GeneratedRegex(@"^\$\{\{(?.*)\}\}$", RegexOptions.Singleline)] + private static partial Regex WrappedExpression(); + + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); + + [GeneratedRegex(@"'(?[^']*)'|""(?[^""]*)""|(?[^\s,()@'""]+)")] + private static partial Regex Token(); +} diff --git a/tests/CheatEngine.Client.Repository.Tests/packages.lock.json b/tests/CheatEngine.Client.Repository.Tests/packages.lock.json new file mode 100644 index 0000000..8823306 --- /dev/null +++ b/tests/CheatEngine.Client.Repository.Tests/packages.lock.json @@ -0,0 +1,212 @@ +{ + "version": 2, + "dependencies": { + "net10.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]" + } + }, + "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.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]" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Architecture/GeneratedLuaSurfaceRatchetTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Architecture/GeneratedLuaSurfaceRatchetTests.cs new file mode 100644 index 0000000..1e298ab --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Architecture/GeneratedLuaSurfaceRatchetTests.cs @@ -0,0 +1,382 @@ +using System.Collections.Immutable; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Emit; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Architecture; + +/// +/// C0 ratchet of the CheatEngine.SDK surface of generated Client Lua module code: the exact allowlist of the SDK +/// members the generated registration adapter uses. It is the Lua registration API that CheatEngine.SDK 2.0.0 +/// imposes (an admitted operation whose state the SDK-generated TryRegisterLuaFunctions and the lease release +/// take, and the getters of the results they return), not raw stack access, and it may only shrink. +/// +/// +/// The members are read from the emitted image with System.Reflection.Metadata: every member reference whose +/// declaring type is in a CheatEngine.SDK namespace, whatever the SDK assembly, except the constructors of the +/// attributes the test source applies ([LuaFunction], [LuaGlobal]), which are metadata and not calls. No +/// generated code runs. A second check proves, on the semantic model, that only the generated adapter calls +/// CheatEngine.SDK: the module and the registrar name SDK types and enum values only. +/// +public sealed class GeneratedLuaSurfaceRatchetTests +{ + private const string Guidance = + "Generated Client Lua module code may use only the SDK-imposed registration API listed in " + + "SdkImposedRegistrationSurface; a new member is a reviewed addition with its reason, never raw Lua stack access."; + + private const string Admission = + "SDK-imposed admission: the SDK registration and the lease release take the state of an admitted operation"; + + private const string Registration = "SDK-imposed registration API: a getter of the result the SDK registration returns"; + + private const string Release = "SDK-imposed registration API: the lease release and the getters of its outcome"; + + /// The exact SDK surface of generated module code, one reason per member; it may only shrink. + private static readonly SdkImposedMember[] SdkImposedRegistrationSurface = + [ + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure::get_LuaStatus()->CheatEngine.SDK.Lua.Calls.LuaStatus", + Registration + ": the failed protected operation's status, copied as text into the failure message"), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure::get_Name()->string", Registration), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationLease::ReleaseWithOutcome()->CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome", + Release + ": consumes a lease whose Lua universe is gone (Detached, ExternalStateReset) without any Lua call"), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationLease::ReleaseWithOutcome(CheatEngine.SDK.Lua.State.LuaState)->CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome", + Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure::get_Name()->string", + Release + ": the failed global names of LuaModuleReleaseOutcome.FailedExports"), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome::get_Failures()->System.Collections.Generic.IReadOnlyList`1", + Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome::get_Kind()->CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind", + Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome::get_RemainingCount()->int32", Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome::get_RemovedCount()->int32", Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome::get_ReplacementCount()->int32", Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome::get_RestoredCount()->int32", Release), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationResult::get_Failure()->System.Nullable`1", + Registration), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationResult::get_Kind()->CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind", + Registration), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationResult::get_Lease()->CheatEngine.SDK.Lua.Registration.LuaRegistrationLease", + Registration), + new("CheatEngine.SDK.Lua.Registration.LuaRegistrationResult::get_Rollback()->CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome", + Registration + ": the compensation outcome of a failed publication"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntime::TryAcquireOperationWithOutcome(CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation&)->CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus", + Admission + "; the factual LuaAdmissionStatus is classified by the registrar, never an exception message"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation::Dispose()->void", + Admission + "; ends the admission before control returns to Cheat Engine"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation::get_State()->CheatEngine.SDK.Lua.State.LuaState", + Admission + "; passed to the SDK only, never read or written by generated code") + ]; + + private const string ModuleSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Registration; + using CheatEngine.SDK.Lua.State; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + + // Stands for the SDK LuaBindings output; it adds no SDK member reference of its own. + public static LuaRegistrationResult TryRegisterLuaFunctions(LuaState state, + LuaRegistrationCollisionPolicy collisionPolicy = LuaRegistrationCollisionPolicy.RejectExisting) => default; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + """; + + private const string OperationSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal sealed class SdkSnapshot { } + internal readonly record struct Snapshot(int Value); + internal readonly struct SnapshotMapper : ILuaResultMapper + { + public static Snapshot Map(SdkSnapshot source) => new(0); + } + + internal static partial class Globals + { + [CheatEngineLuaOperation] + [LuaGlobal("getVersion")] + public static partial int ReadVersion(int address); + + [CheatEngineLuaOperation] + [LuaGlobal("tryGetVersion")] + public static partial bool TryReadVersion(int address, out int version); + + [CheatEngineLuaOperation(typeof(SnapshotMapper))] + [LuaGlobal("getSnapshot")] + public static partial SdkSnapshot ReadSnapshot(); + } + """; + + // Stands for the SDK LuaBindings implementation of the [LuaGlobal] partial methods. + private const string OperationImplementations = + """ + namespace TestPlugin; + internal static partial class Globals + { + public static partial int ReadVersion(int address) => 0; + + public static partial bool TryReadVersion(int address, out int version) + { + version = 0; + return true; + } + + public static partial SdkSnapshot ReadSnapshot() => new(); + } + """; + + [Fact] + public void GeneratedModuleSdkLuaSurfaceIsTheExactSdkImposedRegistrationApi() + { + GeneratorRun run = GeneratorRun.Execute(ModuleSource); + Assert.Empty(run.Diagnostics); + + string[] actual = [.. SdkMemberReferences(Emit(run.OutputCompilation)).Order(StringComparer.Ordinal)]; + string[] allowed = [.. SdkImposedRegistrationSurface.Select(static member => member.Member)]; + + string[] added = [.. actual.Except(allowed, StringComparer.Ordinal)]; + string[] removed = [.. allowed.Except(actual, StringComparer.Ordinal)]; + Assert.True(added.Length == 0, + "New CheatEngine.SDK members in generated module code:" + Environment.NewLine + + string.Join(Environment.NewLine, added) + + Environment.NewLine + Guidance); + Assert.True(removed.Length == 0, + "These CheatEngine.SDK members are no longer used; shrink SdkImposedRegistrationSurface:" + Environment.NewLine + + string.Join(Environment.NewLine, removed)); + Assert.Equal(allowed.Order(StringComparer.Ordinal), allowed); + Assert.All(SdkImposedRegistrationSurface, static member => + Assert.False(string.IsNullOrWhiteSpace(member.Reason), member.Member + " needs a reason.")); + } + + [Fact] + public void OnlyTheGeneratedAdapterCallsCheatEngineSdk() + { + GeneratorRun run = GeneratorRun.Execute(ModuleSource); + List violations = []; + int adapterCalls = 0; + foreach (SyntaxTree generated in run.OutputCompilation.SyntaxTrees.Where(static tree => + tree.FilePath.EndsWith(".g.cs", StringComparison.Ordinal))) + { + bool isAdapter = generated.FilePath.EndsWith(RegistrarEmitter.AdapterHintName, StringComparison.Ordinal); + SemanticModel model = run.OutputCompilation.GetSemanticModel(generated); + foreach (SimpleNameSyntax name in generated.GetRoot(TestContext.Current.CancellationToken).DescendantNodes() + .OfType()) + { + ISymbol? symbol = model.GetSymbolInfo(name, TestContext.Current.CancellationToken).Symbol; + if (symbol is null or ITypeSymbol or INamespaceSymbol || symbol.ContainingType is not { } owner || + owner.ContainingAssembly?.Name.StartsWith("CheatEngine.SDK", StringComparison.Ordinal) != true) + { + continue; + } + + if (isAdapter) + { + adapterCalls++; + } + else if (owner.TypeKind != TypeKind.Enum) + { + // The module and the registrar may name SDK enum values (constants), never call an SDK member. + violations.Add($"{Path.GetFileName(generated.FilePath)}: {owner.ToDisplayString()}.{symbol.Name}"); + } + } + } + + Assert.True(violations.Count == 0, + "Only the generated CheatEngine.SDK adapter may call CheatEngine.SDK:" + Environment.NewLine + + string.Join(Environment.NewLine, violations) + Environment.NewLine + Guidance); + Assert.True(adapterCalls > 0, "The generated adapter was expected to call CheatEngine.SDK."); + } + + [Fact] + public void OperationAdaptersStillUseNoLuaStateOrLuaRef() + { + GeneratorRun run = GeneratorRun.Execute(OperationSource); + Assert.Empty(run.Diagnostics); + foreach (GeneratedSourceResult source in run.GeneratedSources) + { + string text = source.SourceText.ToString(); + Assert.DoesNotContain("LuaState", text, StringComparison.Ordinal); + Assert.DoesNotContain("LuaRef", text, StringComparison.Ordinal); + Assert.DoesNotContain("LuaRuntime", text, StringComparison.Ordinal); + } + + Compilation compilation = run.OutputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(OperationImplementations, + new CSharpParseOptions(LanguageVersion.CSharp14), cancellationToken: TestContext.Current.CancellationToken)); + Assert.Empty(SdkMemberReferences(Emit(compilation))); + } + + [Fact] + public void TheSurfaceCheckSeesEverySdkAssemblyButNotAttributeMetadata() + { + // A call into a CheatEngine.SDK assembly other than the Lua one must not pass unseen. + Compilation compilation = GeneratorRun.Execute(ModuleSource).OutputCompilation.AddSyntaxTrees( + CSharpSyntaxTree.ParseText( + "namespace TestPlugin; internal static class Probe { internal static object Create() => " + + "new CheatEngine.SDK.Engine.Errors.EngineOperationFailedException(\"probe\", \"probe\"); }", + new CSharpParseOptions(LanguageVersion.CSharp14), cancellationToken: TestContext.Current.CancellationToken)); + + SortedSet members = SdkMemberReferences(Emit(compilation)); + + Assert.Contains("CheatEngine.SDK.Engine.Errors.EngineOperationFailedException::.ctor(string,string)->void", members); + // The [LuaFunction] attributes of the test bindings are metadata of the test source, not calls. + Assert.DoesNotContain(members, static member => member.Contains("LuaFunctionAttribute", StringComparison.Ordinal)); + } + + private static byte[] Emit(Compilation compilation) + { + using MemoryStream image = new(); + EmitResult result = compilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics)); + return image.ToArray(); + } + + private static SortedSet SdkMemberReferences(byte[] image) + { + using PEReader reader = new(new MemoryStream(image)); + MetadataReader metadata = reader.GetMetadataReader(); + HashSet attributeConstructors = + [ + .. metadata.CustomAttributes.Select(handle => metadata.GetCustomAttribute(handle).Constructor) + ]; + SortedSet members = new(StringComparer.Ordinal); + foreach (MemberReferenceHandle handle in metadata.MemberReferences) + { + if (attributeConstructors.Contains(handle)) + { + continue; + } + + MemberReference member = metadata.GetMemberReference(handle); + string declaringType = member.Parent.Kind switch + { + HandleKind.TypeReference => TypeReferenceName(metadata, (TypeReferenceHandle) member.Parent), + HandleKind.TypeSpecification => metadata.GetTypeSpecification((TypeSpecificationHandle) member.Parent) + .DecodeSignature(SignatureNames.Instance, null), + _ => string.Empty + }; + if (!declaringType.StartsWith("CheatEngine.SDK.", StringComparison.Ordinal)) + { + continue; + } + + string name = metadata.GetString(member.Name); + MethodSignature signature = member.DecodeMethodSignature(SignatureNames.Instance, null); + members.Add($"{declaringType}::{name}({string.Join(",", signature.ParameterTypes)})->{signature.ReturnType}"); + } + + return members; + } + + private static string TypeReferenceName(MetadataReader metadata, TypeReferenceHandle handle) + { + TypeReference reference = metadata.GetTypeReference(handle); + string name = metadata.GetString(reference.Name); + if (reference.ResolutionScope.Kind == HandleKind.TypeReference) + { + return TypeReferenceName(metadata, (TypeReferenceHandle) reference.ResolutionScope) + "+" + name; + } + + string ns = metadata.GetString(reference.Namespace); + return ns.Length == 0 ? name : ns + "." + name; + } + + private sealed record SdkImposedMember(string Member, string Reason); + + /// Type names in the display convention of the Client architecture ratchet (primitives lower-case). + private sealed class SignatureNames : ISignatureTypeProvider + { + public static readonly SignatureNames Instance = new(); + + public string GetArrayType(string elementType, ArrayShape shape) + { + return elementType + "[" + new string(',', shape.Rank - 1) + "]"; + } + + public string GetByReferenceType(string elementType) + { + return elementType + "&"; + } + + public string GetFunctionPointerType(MethodSignature signature) + { + return "fnptr(" + string.Join(",", signature.ParameterTypes) + ")->" + signature.ReturnType; + } + + public string GetGenericInstantiation(string genericType, ImmutableArray typeArguments) + { + return genericType + "<" + string.Join(",", typeArguments) + ">"; + } + + public string GetGenericMethodParameter(object? genericContext, int index) + { + return "!!" + index; + } + + public string GetGenericTypeParameter(object? genericContext, int index) + { + return "!" + index; + } + + public string GetModifiedType(string modifier, string unmodifiedType, bool isRequired) + { + return unmodifiedType; + } + + public string GetPinnedType(string elementType) + { + return elementType; + } + + public string GetPointerType(string elementType) + { + return elementType + "*"; + } + + public string GetPrimitiveType(PrimitiveTypeCode typeCode) + { + return typeCode.ToString().ToLowerInvariant(); + } + + public string GetSZArrayType(string elementType) + { + return elementType + "[]"; + } + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + TypeDefinition definition = reader.GetTypeDefinition(handle); + string ns = reader.GetString(definition.Namespace); + string name = reader.GetString(definition.Name); + return ns.Length == 0 ? name : ns + "." + name; + } + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + return TypeReferenceName(reader, handle); + } + + public string GetTypeFromSpecification(MetadataReader reader, object? genericContext, + TypeSpecificationHandle handle, byte rawTypeKind) + { + return reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj index 0dedb09..89fd5f1 100644 --- a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj @@ -4,6 +4,27 @@ + + + + + + + + + + + + + diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs index 15d1a2d..b9c83b4 100644 --- a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs @@ -13,12 +13,6 @@ namespace CheatEngine.Client.SourceGenerators.Lua.Tests; public sealed class CheatEngineLuaGeneratorTests { - private const string ModuleSource = ModulePrefix + - """ - [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] - internal sealed partial class PluginLuaModule : ILuaModule; - """; - private const string ModulePrefix = """ using CheatEngine.Client.Lua; @@ -64,7 +58,7 @@ internal static partial class Globals """ using CheatEngine.Client.Lua; using CheatEngine.SDK.Annotations.Lua; - using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.Registration; using CheatEngine.SDK.Lua.State; namespace TestPlugin; internal static partial class PluginLuaBindings @@ -75,8 +69,8 @@ internal static partial class PluginLuaBindings [LuaFunction("ping")] public static int Ping() => 1; - public static LuaStatus RegisterLuaFunctions(LuaState state) => default; - public static LuaStatus UnregisterLuaFunctions(LuaState state) => default; + public static LuaRegistrationResult TryRegisterLuaFunctions(LuaState state, + LuaRegistrationCollisionPolicy collisionPolicy = LuaRegistrationCollisionPolicy.RejectExisting) => default; } [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] @@ -109,42 +103,12 @@ internal static partial class Globals private static readonly string[] InvalidModuleDiagnosticIds = ["CECLUA1001", "CECLUA1005"]; - [Fact] - public void ModuleAdapterSnapshotUsesOneAdmittedOperationAndPreflightsEveryExport() - { - GeneratorRun run = GeneratorRun.Execute(ModuleSource); - - Assert.Empty(run.Diagnostics); - string generated = run.GeneratedText("PluginLuaModule.CheatEngineLuaModule.g.cs"); - Assert.Equal( - NormalizeLineEndings(""" - // - #nullable enable - - namespace TestPlugin; - - internal partial class PluginLuaModule : global::CheatEngine.Client.Lua.IDescribedLuaModule - { - /// Initializes a Lua module instance for activation-scoped dependency injection. - public PluginLuaModule() - { - } - - private static readonly global::CheatEngine.Client.Lua.LuaModuleDescriptor s_descriptor = - new global::CheatEngine.Client.Lua.LuaModuleDescriptor( - "plugin", - global::System.Collections.Immutable.ImmutableArray.Create(new global::CheatEngine.Client.Lua.LuaExportDescriptor("status"), new global::CheatEngine.Client.Lua.LuaExportDescriptor("ping"))); - - """ + "\n"), - NormalizeLineEndings(generated[..generated.IndexOf("\t/// ", StringComparison.Ordinal)])); - Assert.Equal(2, Count(generated, "LuaRuntime.AcquireOperation()")); - Assert.Contains("EnsureExportsAreVacant(operation.State);", generated, StringComparison.Ordinal); - Assert.Contains("state.TryGetGlobal(\"status\"u8)", generated, StringComparison.Ordinal); - Assert.Contains("state.TryGetGlobal(\"ping\"u8)", generated, StringComparison.Ordinal); - Assert.Contains( - "finally\n\t\t{\n\t\t\t_ = global::TestPlugin.PluginLuaBindings.UnregisterLuaFunctions(operation.State);", - NormalizeLineEndings(generated), StringComparison.Ordinal); - } + /// + /// The compiler's documentation diagnostics for malformed XML, a parameter or type parameter reference that + /// names nothing, and a cref that is malformed or does not resolve. + /// + private static readonly string[] DocumentationDiagnosticIds = + ["CS1570", "CS1572", "CS1573", "CS1574", "CS1580", "CS1581", "CS1584", "CS1658", "CS1723", "CS1734"]; [Fact] public void ModuleAdapterWithMultipleExportsCompilesAgainstTheSdkRegistrationContract() @@ -184,7 +148,7 @@ public void GeneratedInternalModuleHasAPublicConstructorAndResolvesViaAddLuaModu MethodInfo addLuaModule = Assert.Single( typeof(CheatEngineClientBuilder).GetMethods(BindingFlags.Instance | BindingFlags.Public), static method => method.Name == nameof(CheatEngineClientBuilder.AddLuaModule) && - method.IsGenericMethodDefinition); + method.IsGenericMethodDefinition); Assert.Same(builder, addLuaModule.MakeGenericMethod(moduleType).Invoke(builder, null)); using ServiceProvider provider = services.BuildServiceProvider(); @@ -196,7 +160,7 @@ public void GeneratedInternalModuleHasAPublicConstructorAndResolvesViaAddLuaModu public void ExplicitPublicConstructorIsPreservedForDependencyInjection() { GeneratorRun run = GeneratorRun.Execute(ModulePrefix + - """ + """ public sealed class ModuleDependency; [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] @@ -217,7 +181,7 @@ public PluginLuaModule(ModuleDependency dependency) public void OnlyNonPublicExplicitConstructorsProduceAnActionableDiagnostic() { GeneratorRun run = GeneratorRun.Execute(ModulePrefix + - """ + """ [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] internal sealed partial class PluginLuaModule : ILuaModule { @@ -245,10 +209,47 @@ public void ScalarOperationAdapterEmitsAReadonlyValueAndTypedFactory() generated, StringComparison.Ordinal); Assert.Contains("global::TestPlugin.Globals.ReadVersion(_address)", generated, StringComparison.Ordinal); Assert.Contains("CheatEngineFailureKind.LuaError", generated, StringComparison.Ordinal); + Assert.Contains( + "public static global::System.Int32 Execute(this global::CheatEngine.Client.Lua.ILuaClient client, in ReadVersionLuaOperation operation,", + generated, StringComparison.Ordinal); + Assert.Contains("return client.Execute(in operation, cancellationToken);", + generated, StringComparison.Ordinal); + Assert.Contains("public static bool TryExecute(this global::CheatEngine.Client.Lua.ILuaClient client, in ReadVersionLuaOperation operation,", + generated, StringComparison.Ordinal); Assert.DoesNotContain("LuaState", generated, StringComparison.Ordinal); Assert.DoesNotContain("LuaRef", generated, StringComparison.Ordinal); } + [Fact] + public void GeneratedOperationExtensionsInferTheOperationAndResultTypes() + { + GeneratorRun run = GeneratorRun.Execute(ScalarOperationSource); + Assert.Empty(run.Diagnostics); + + // Without the generated extensions, ILuaClient.Execute cannot infer TResult from the operation alone. + Compilation compilation = run.OutputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText( + """ + namespace TestPlugin; + internal static partial class Globals + { + public static partial int ReadVersion(int address) => address; + } + + internal static class Consumer + { + internal static int Run(CheatEngine.Client.Lua.ILuaClient client) + { + Globals.ReadVersionLuaOperation operation = Globals.CreateReadVersionLuaOperation(4); + int value = client.Execute(operation); + return client.TryExecute(operation, out int second, out _) ? value + second : value; + } + } + """, new CSharpParseOptions(LanguageVersion.CSharp14), + cancellationToken: TestContext.Current.CancellationToken)); + + AssertNoCompilerDiagnostics(compilation); + } + [Fact] public void OutResultOperationProjectsTheSingleSdkOutValueAndMapsFalseToAFailure() { @@ -274,6 +275,97 @@ public void MappedOperationUsesTheDeclaredStaticMapperAndProjectsTheResultType() Assert.Contains("global::TestPlugin.SnapshotMapper.Map(source)", generated, StringComparison.Ordinal); } + [Fact] + public void GeneratedOperationExtensionsDocumentTheExceptionsOfTheLuaClientPair() + { + GeneratorRun scalar = GeneratorRun.Execute(ScalarOperationSource); + GeneratorRun mapped = GeneratorRun.Execute(MappedOperationSource); + Assert.Empty(scalar.Diagnostics); + Assert.Empty(mapped.Diagnostics); + string generated = scalar.GeneratedText("ReadVersion.CheatEngineLuaOperation.g.cs"); + string generatedWithMapper = mapped.GeneratedText("ReadSnapshot.CheatEngineLuaOperation.g.cs"); + + // ILuaClient.Execute and TryExecute document the same Client exceptions; the extension also checks its client. + const string Results = "global::CheatEngine.Client.Results."; + Assert.Equal( + [ + "global::System.ArgumentNullException", Results + "CheatEngineActivationExpiredException", + Results + "CheatEngineInvalidStateException", Results + "CheatEngineOperationCanceledException", + Results + "CheatEngineOperationException" + ], + DocumentedExceptions(generated, " Execute(this ")); + Assert.Equal( + [ + "global::System.ArgumentNullException", Results + "CheatEngineActivationExpiredException", + Results + "CheatEngineInvalidStateException" + ], + DocumentedExceptions(generated, " TryExecute(this ")); + Assert.Contains("/// ", + generated, StringComparison.Ordinal); + Assert.DoesNotContain("result mapper", generated, StringComparison.Ordinal); + Assert.Equal(2, + generatedWithMapper.Split("/// An exception that the result mapper throws").Length - 1); + + // Every cref and paramref of the generated documentation resolves. + AssertNoDocumentationDiagnostics(scalar); + AssertNoDocumentationDiagnostics(mapped); + } + + [Fact] + public void TheMapperRunsOutsideTheBindingFailureClassification() + { + // The mapper is application code: an exception it throws must leave TryExecute unchanged, never be classified + // as a binding failure, so its call follows the catch clauses that classify the SDK binding call. + GeneratorRun run = GeneratorRun.Execute(MappedOperationSource); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("ReadSnapshot.CheatEngineLuaOperation.g.cs"); + int binding = generated.IndexOf("global::TestPlugin.Globals.ReadSnapshot()", StringComparison.Ordinal); + int lastCatch = generated.LastIndexOf("catch (global::System.Exception exception)", StringComparison.Ordinal); + int mapper = generated.IndexOf("global::TestPlugin.SnapshotMapper.Map(source)", StringComparison.Ordinal); + Assert.True(binding >= 0 && binding < lastCatch, generated); + Assert.True(mapper > lastCatch, generated); + + // The SDK source value is assigned inside the try block and read after it. + AssertNoCompilerDiagnostics(run.OutputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText( + """ + namespace TestPlugin; + internal static partial class Globals + { + public static partial SdkSnapshot ReadSnapshot() => new(); + } + """, new CSharpParseOptions(LanguageVersion.CSharp14), + cancellationToken: TestContext.Current.CancellationToken))); + } + + [Fact] + public void AnOutResultOperationReadsItsSourceAfterTheBindingFailureClassification() + { + GeneratorRun run = GeneratorRun.Execute(OutResultOperationSource); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("TryReadVersion.CheatEngineLuaOperation.g.cs"); + int declaration = generated.IndexOf("global::System.Int32 source;", StringComparison.Ordinal); + int binding = generated.IndexOf("global::TestPlugin.Globals.TryReadVersion(_address, out source)", + StringComparison.Ordinal); + int lastCatch = generated.LastIndexOf("catch (global::System.Exception exception)", StringComparison.Ordinal); + Assert.True(declaration >= 0 && declaration < binding && binding < lastCatch, generated); + Assert.True(generated.IndexOf("result = source;", StringComparison.Ordinal) > lastCatch, generated); + AssertNoCompilerDiagnostics(run.OutputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText( + """ + namespace TestPlugin; + internal static partial class Globals + { + public static partial bool TryReadVersion(int address, out int version) + { + version = address; + return true; + } + } + """, new CSharpParseOptions(LanguageVersion.CSharp14), + cancellationToken: TestContext.Current.CancellationToken))); + } + [Theory] [InlineData( "[CheatEngineLuaModule(typeof(PluginLuaBindings), \" \")] internal sealed partial class PluginLuaModule { }")] @@ -296,8 +388,8 @@ public void AbstractOrFileLocalModulesProduceDeterministicShapeDiagnostics(strin string expectedMessageFragment) { string source = ModulePrefix + - "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"plugin\")] " + modifier + - " partial class PluginLuaModule { }"; + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"plugin\")] " + modifier + + " partial class PluginLuaModule { }"; Diagnostic first = Assert.Single(GeneratorRun.Execute(source).Diagnostics .Where(static diagnostic => diagnostic.Id == "CECLUA1001")); @@ -643,40 +735,6 @@ public static ILuaOperation> Create() => Assert.True(downstreamEmit.Success, string.Join(Environment.NewLine, downstreamEmit.Diagnostics)); } - [Fact] - public void UnchangedInputReusesTheIncrementalOutput() - { - GeneratorRun first = GeneratorRun.Execute(ModuleSource); - Compilation unchangedCompilation = first.OutputCompilation.RemoveAllSyntaxTrees() - .AddSyntaxTrees(CSharpSyntaxTree.ParseText(ModuleSource, - cancellationToken: TestContext.Current.CancellationToken)); - GeneratorRun second = GeneratorRun.Execute(first.Driver, unchangedCompilation); - - Assert.Empty(second.Diagnostics); - Assert.Equal(first.GeneratedSources.Select(static source => source.SourceText.ToString()), - second.GeneratedSources.Select(static source => source.SourceText.ToString())); - GeneratorRunResult result = Assert.Single(second.Result.Results); - Assert.NotEmpty(result.TrackedSteps); - } - - private static int Count(string value, string fragment) - { - int count = 0; - int start = 0; - while ((start = value.IndexOf(fragment, start, StringComparison.Ordinal)) >= 0) - { - count++; - start += fragment.Length; - } - - return count; - } - - private static string NormalizeLineEndings(string value) - { - return value.Replace("\r\n", "\n", StringComparison.Ordinal); - } - private static string CreateMappedOperationSource(string resultType, string? additionalDeclarations = null, string sourceType = "SdkSnapshot") { @@ -689,6 +747,44 @@ private static string CreateMappedOperationSource(string resultType, string? add "\t[LuaGlobal(\"unsafe\")]", "\tpublic static partial " + sourceType + " GetUnsafe();", "}"); } + /// The exception crefs documented right above the first declaration that contains a marker. + private static string[] DocumentedExceptions(string generated, string declarationMarker) + { + const string Prefix = "/// line.Trim())]; + int index = Array.FindIndex(lines, line => line.Contains(declarationMarker, StringComparison.Ordinal)); + Assert.True(index > 0, generated); + List crefs = []; + while (--index >= 0 && lines[index].StartsWith("///", StringComparison.Ordinal)) + { + if (lines[index].StartsWith(Prefix, StringComparison.Ordinal)) + { + crefs.Insert(0, lines[index][Prefix.Length..lines[index].IndexOf('"', Prefix.Length)]); + } + } + + return [.. crefs]; + } + + /// + /// Compiles the generator's output with documentation diagnostics on, as a consumer that generates its XML + /// documentation does, and asserts that no documentation comment is malformed or refers to nothing. + /// + private static void AssertNoDocumentationDiagnostics(GeneratorRun run) + { + CSharpParseOptions options = new(LanguageVersion.CSharp14, DocumentationMode.Diagnose); + Compilation compilation = run.OutputCompilation.RemoveAllSyntaxTrees().AddSyntaxTrees( + run.OutputCompilation.SyntaxTrees.Select(tree => CSharpSyntaxTree.ParseText( + tree.GetText(TestContext.Current.CancellationToken), options, tree.FilePath, + TestContext.Current.CancellationToken))); + Diagnostic[] diagnostics = + [ + .. compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => DocumentationDiagnosticIds.Contains(diagnostic.Id, StringComparer.Ordinal)) + ]; + Assert.Empty(diagnostics); + } + private static void AssertNoCompilerDiagnostics(Compilation compilation) { Diagnostic[] diagnostics = diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Composition/RealSdkGeneratorCompositionTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Composition/RealSdkGeneratorCompositionTests.cs new file mode 100644 index 0000000..69b4b64 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Composition/RealSdkGeneratorCompositionTests.cs @@ -0,0 +1,187 @@ +using System.Reflection; + +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Emit; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Composition; + +/// +/// CRIT-07: the Client generator and the real CheatEngine.SDK LuaBindings generator of the pinned package run on the +/// same compilation, and what each emits must compile against what the other emits. The other generator tests replace +/// the SDK output with hand-written stubs; this is the proof of the contract between the two generators. +/// +/// +/// The SDK generator is loaded from +/// $(NuGetPackageRoot)cheatengine.sdk/$(CheatEngineSdkVersion)/analyzers/dotnet/cs, which the test project +/// passes as assembly metadata with the pin itself: never a user path. +/// +public sealed class RealSdkGeneratorCompositionTests +{ + private const string DirectoryMetadataKey = "CheatEngine.Client.Tests.SdkGeneratorDirectory"; + + private const string VersionMetadataKey = "CheatEngine.Client.Tests.SdkVersion"; + + private const string Source = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + + internal static partial class PluginLuaBindings + { + [LuaFunction("composition_status")] + public static string Status() => "ok"; + + [LuaFunction("composition_add")] + public static long Add(long left, long right) => left + right; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "composition")] + internal sealed partial class PluginLuaModule : ILuaModule; + + internal static partial class Globals + { + [CheatEngineLuaOperation] + [LuaGlobal("getVersion")] + public static partial int ReadVersion(int address); + + [CheatEngineLuaOperation] + [LuaGlobal("tryGetVersion")] + public static partial bool TryReadVersion(long address, out long version); + } + + internal static class Consumer + { + internal static long Run(ILuaClient client) + { + int version = client.Execute(Globals.CreateReadVersionLuaOperation(1)); + return client.TryExecute(Globals.CreateTryReadVersionLuaOperation(2), out long value, out _) + ? value + version + : version; + } + } + """; + + private static readonly Lazy SRun = new(Run, LazyThreadSafetyMode.ExecutionAndPublication); + + [Fact] + public void TheSdkGeneratorIsLoadedFromThePinnedPackageFolder() + { + string directory = SdkGeneratorDirectory(); + + string[] segments = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + .Split(Path.DirectorySeparatorChar); + Assert.True(Path.IsPathFullyQualified(directory), directory); + Assert.Equal(["cheatengine.sdk", Metadata(VersionMetadataKey), "analyzers", "dotnet", "cs"], segments[^5..]); + Assert.True(File.Exists(Path.Combine(directory, "CheatEngine.SDK.SourceGenerators.LuaBindings.dll"))); + Assert.True(File.Exists(Path.Combine(directory, "CheatEngine.SDK.SourceGenerators.Shared.dll"))); + } + + [Fact] + public void BothGeneratorsRunAndTheirOutputCompilesWithoutDiagnostics() + { + GeneratorRun run = SRun.Value; + + Assert.Equal(2, run.Result.Results.Length); + Assert.All(run.Result.Results, static result => Assert.Null(result.Exception)); + Assert.Empty(run.Diagnostics.Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning)); + Diagnostic[] compilerDiagnostics = + [ + .. run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning) + ]; + Assert.True(compilerDiagnostics.Length == 0, string.Join(Environment.NewLine, compilerDiagnostics)); + Assert.NotEmpty(Emit(run)); + } + + [Fact] + public void TheModuleRegistersOnlyThroughTheSdkRegistrationSet() + { + GeneratorRun run = SRun.Value; + string sdkRegistration = SdkGeneratedText(run, "TryRegisterLuaFunctions("); + + // The SDK-generated ownership-aware registration publishes through LuaRegistrationSet.Register, which wraps every + // thunk in the closure that captures the attachment and Lua state identity: a function a script kept after disable + // or reset raises an ordinary Lua error instead of entering the plugin (CRIT-07). + Assert.Contains("LuaRegistrationSet.Register(", sdkRegistration, StringComparison.Ordinal); + string[] called = [.. IlCallScanner.CalledMethodNames(Emit(run))]; + Assert.Contains("TryRegisterLuaFunctions", called); + Assert.DoesNotContain("RegisterLuaFunctions", called); + Assert.DoesNotContain("UnregisterLuaFunctions", called); + } + + [Fact] + public void IntegerResultsAreReadWithTheSdkMarshallersThatRefuseAFloatAtOrAbove2Pow53() + { + GeneratorRun run = SRun.Value; + string globals = SdkGeneratedText(run, "ReadVersion(int address)"); + + // CRIT-07: the SDK reads an integer result with its integer marshaller, which refuses a Lua float at or above + // 2^53 instead of rounding it. The throwing form then raises a LuaException, which the generated Client operation + // reports as a LuaError failure (see OperationRefusalTests); the Try form returns false, reported the same way. + Assert.Contains("Int32Marshaller", globals, StringComparison.Ordinal); + Assert.Contains("Int64Marshaller", globals, StringComparison.Ordinal); + Assert.Contains("ThrowUnexpectedResult", globals, StringComparison.Ordinal); + } + + private static GeneratorRun Run() + { + ISourceGenerator sdkGenerator = LoadSdkLuaBindingsGenerator(); + return GeneratorRun.ExecuteFiles([("Composition.cs", Source)], SdkAssemblyReferences(), [sdkGenerator]); + } + + private static ISourceGenerator LoadSdkLuaBindingsGenerator() + { + string directory = SdkGeneratorDirectory(); + // The shared model assembly must be loadable before the generator that depends on it. + _ = System.Reflection.Assembly.LoadFrom(Path.Combine(directory, "CheatEngine.SDK.SourceGenerators.Shared.dll")); + System.Reflection.Assembly generators = + System.Reflection.Assembly.LoadFrom(Path.Combine(directory, "CheatEngine.SDK.SourceGenerators.LuaBindings.dll")); + Type generatorType = Assert.Single(generators.GetTypes(), static type => + typeof(IIncrementalGenerator).IsAssignableFrom(type) && type.GetCustomAttribute() is not null); + return ((IIncrementalGenerator) Activator.CreateInstance(generatorType)!).AsSourceGenerator(); + } + + private static string SdkGeneratorDirectory() + { + return Metadata(DirectoryMetadataKey); + } + + private static string Metadata(string key) + { + AssemblyMetadataAttribute metadata = Assert.Single( + typeof(RealSdkGeneratorCompositionTests).Assembly.GetCustomAttributes(), + attribute => attribute.Key == key); + Assert.False(string.IsNullOrWhiteSpace(metadata.Value), $"{key} is not set by the test project."); + return metadata.Value!; + } + + // Every CheatEngine.SDK assembly next to the tests: the SDK-generated code uses the Lua and interop assemblies. + private static MetadataReference[] SdkAssemblyReferences() + { + return + [ + .. Directory.EnumerateFiles(AppContext.BaseDirectory, "CheatEngine.SDK*.dll") + .Order(StringComparer.Ordinal) + .Select(static path => MetadataReference.CreateFromFile(path)) + ]; + } + + private static string SdkGeneratedText(GeneratorRun run, string fragment) + { + GeneratorRunResult sdk = Assert.Single(run.Result.Results, static result => + result.Generator.GetGeneratorType().Assembly.GetName().Name == "CheatEngine.SDK.SourceGenerators.LuaBindings"); + return Assert.Single(sdk.GeneratedSources, source => + source.SourceText.ToString().Contains(fragment, StringComparison.Ordinal)).SourceText.ToString(); + } + + private static byte[] Emit(GeneratorRun run) + { + using MemoryStream image = new(); + EmitResult result = run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics)); + return image.ToArray(); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Diagnostics/CheatEngineLuaDiagnosticCatalogTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Diagnostics/CheatEngineLuaDiagnosticCatalogTests.cs new file mode 100644 index 0000000..e76aa5d --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Diagnostics/CheatEngineLuaDiagnosticCatalogTests.cs @@ -0,0 +1,99 @@ +using System.Text.RegularExpressions; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Diagnostics; + +/// +/// Diagnostic hygiene (DoD F): every descriptor is tracked in the analyzer release file with its category and severity, +/// listed in the generator README, and inside the CECLUA ranges allocated to this generator. +/// +public sealed partial class CheatEngineLuaDiagnosticCatalogTests +{ + private const string ReleaseFile = "AnalyzerReleases.Unshipped.md"; + private const string ShippedReleaseFile = "AnalyzerReleases.Shipped.md"; + private const string GeneratorReadme = "Generator.README.md"; + + [Fact] + public void EveryDescriptorIsTrackedInTheReleaseFileWithMatchingCategoryAndSeverity() + { + Dictionary rows = ReadReleaseRows(); + + foreach (DiagnosticDescriptor descriptor in CheatEngineLuaDiagnostics.All) + { + Assert.True(rows.TryGetValue(descriptor.Id, out (string Category, string Severity) row), + $"{descriptor.Id} is not tracked in {ShippedReleaseFile} or {ReleaseFile}."); + Assert.Equal(descriptor.Category, row.Category); + Assert.Equal(descriptor.DefaultSeverity.ToString(), row.Severity); + } + + Assert.Equal(CheatEngineLuaDiagnostics.All.Select(static descriptor => descriptor.Id).Order(StringComparer.Ordinal), + rows.Keys.Order(StringComparer.Ordinal)); + } + + [Fact] + public void EveryDescriptorIsListedInTheGeneratorReadme() + { + string readme = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Diagnostics", GeneratorReadme)); + + Assert.Contains("\n## Diagnostics", readme.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal); + foreach (DiagnosticDescriptor descriptor in CheatEngineLuaDiagnostics.All) + { + Assert.Contains("| " + descriptor.Id + " | " + descriptor.Title + " |", readme, StringComparison.Ordinal); + Assert.Equal(CheatEngineLuaDiagnostics.HelpLinkUri, descriptor.HelpLinkUri); + } + + Assert.EndsWith("/README.md#diagnostics", CheatEngineLuaDiagnostics.HelpLinkUri, StringComparison.Ordinal); + } + + [Fact] + public void NewDiagnosticIdsStayInsideTheAllocatedRanges() + { + string[] ids = [.. CheatEngineLuaDiagnostics.All.Select(static descriptor => descriptor.Id)]; + + Assert.Equal(ids.Order(StringComparer.Ordinal), ids); + Assert.Equal(ids.Length, ids.Distinct(StringComparer.Ordinal).Count()); + foreach (string id in ids) + { + int number = int.Parse(id["CECLUA".Length..], System.Globalization.CultureInfo.InvariantCulture); + Assert.StartsWith("CECLUA", id, StringComparison.Ordinal); + // Generator ranges: 1001-1006 modules, 1101-1106 operations, 1201-1209 module ownership (C-LUAGEN). 1107-1109 + // (C-CORE-A) and 1301-1309 (C-CORE-B) belong to other lots and must never appear here. + Assert.True(number is (>= 1001 and <= 1006) or (>= 1101 and <= 1106) or (>= 1201 and <= 1209), + $"{id} is outside the ranges allocated to the Lua generator."); + Assert.Same(CheatEngineLuaDiagnostics.All.Single(descriptor => descriptor.Id == id), + CheatEngineLuaDiagnostics.Get(id)); + } + + Assert.Equal(["CECLUA1201", "CECLUA1202", "CECLUA1203", "CECLUA1204"], + ids.Where(static id => id.StartsWith("CECLUA12", StringComparison.Ordinal))); + Assert.All(CheatEngineLuaDiagnostics.All, static descriptor => + { + Assert.Equal(DiagnosticSeverity.Error, descriptor.DefaultSeverity); + Assert.True(descriptor.IsEnabledByDefault); + Assert.Equal(CheatEngineLuaDiagnostics.Category, descriptor.Category); + }); + } + + private static Dictionary ReadReleaseRows() + { + Dictionary rows = new(StringComparer.Ordinal); + IEnumerable lines = File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "Diagnostics", ShippedReleaseFile)) + .Concat(File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "Diagnostics", ReleaseFile))); + foreach (string line in lines) + { + Match match = ReleaseRow().Match(line); + if (match.Success) + { + Assert.True(rows.TryAdd(match.Groups["id"].Value, + (match.Groups["category"].Value.Trim(), match.Groups["severity"].Value.Trim())), + $"{match.Groups["id"].Value} is tracked twice."); + } + } + + return rows; + } + + [GeneratedRegex(@"^\s*(?CECLUA\d{4})\s*\|(?[^|]+)\|(?[^|]+)\|", RegexOptions.CultureInvariant)] + private static partial Regex ReleaseRow(); +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Diagnostics/ModuleShapeDiagnosticTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Diagnostics/ModuleShapeDiagnosticTests.cs new file mode 100644 index 0000000..bdb1aeb --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Diagnostics/ModuleShapeDiagnosticTests.cs @@ -0,0 +1,276 @@ +using System.Globalization; + +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Diagnostics; + +/// +/// Q16 shape diagnostics CECLUA1201-1204 (DoD D.1, D.7): one owner per Lua global, reserved generated members, no +/// inherited module implementation, and look-alike annotation types are refused, each located and deterministic. +/// +public sealed class ModuleShapeDiagnosticTests +{ + private const string AlphaFile = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class AlphaLuaBindings + { + [LuaFunction("shared_status")] + public static string Status() => "alpha"; + + [LuaFunction("alpha_only")] + public static int AlphaOnly() => 1; + } + + [CheatEngineLuaModule(typeof(AlphaLuaBindings), "alpha")] + internal sealed partial class AlphaLuaModule : ILuaModule; + """; + + private const string BetaFile = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class BetaLuaBindings + { + [LuaFunction("shared_status")] + public static string Status() => "beta"; + } + + [CheatEngineLuaModule(typeof(BetaLuaBindings), "beta")] + internal sealed partial class BetaLuaModule : ILuaModule; + """; + + private const string BindingsPrefix = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + } + + """; + + [Fact] + public void DuplicateExportAcrossModulesReportsCECLUA1201AtTheLaterModule() + { + GeneratorRun run = GeneratorRun.ExecuteFiles([("A.cs", AlphaFile), ("B.cs", BetaFile)]); + + Diagnostic diagnostic = Assert.Single(run.Diagnostics); + Assert.Equal("CECLUA1201", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Equal( + "Lua global 'shared_status' is exported by Lua module 'TestPlugin.AlphaLuaModule' and again by Lua module 'TestPlugin.BetaLuaModule'; a Lua global has a single owning module per plugin assembly, so remove the export from one of the bindings types", + diagnostic.GetMessage(CultureInfo.InvariantCulture)); + FileLinePositionSpan span = diagnostic.Location.GetLineSpan(); + Assert.Equal("B.cs", span.Path); + Assert.Equal(LineOf(BetaFile, "[CheatEngineLuaModule("), span.StartLinePosition.Line); + // A duplicate owner is refused at build time; both modules and the registrar are still emitted so no cascading + // error hides it. + Assert.Equal(4, run.GeneratedSources.Length); + } + + [Fact] + public void DuplicateExportDiagnosticIsIndependentOfDeclarationOrder() + { + GeneratorRun alphaFirst = GeneratorRun.ExecuteFiles([("A.cs", AlphaFile), ("B.cs", BetaFile)]); + GeneratorRun betaFirst = GeneratorRun.ExecuteFiles([("B.cs", BetaFile), ("A.cs", AlphaFile)]); + GeneratorRun sameFile = GeneratorRun.ExecuteFiles([("One.cs", BetaFile + "\n" + WithoutHeader(AlphaFile))]); + + Diagnostic first = Assert.Single(alphaFirst.Diagnostics); + Diagnostic second = Assert.Single(betaFirst.Diagnostics); + Assert.Equal(first.GetMessage(CultureInfo.InvariantCulture), second.GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(first.Location.GetLineSpan(), second.Location.GetLineSpan()); + + // Inside one file the later declaration is the one reported, whatever the module names are. + Diagnostic inOneFile = Assert.Single(sameFile.Diagnostics); + Assert.Contains("'TestPlugin.BetaLuaModule' and again by Lua module 'TestPlugin.AlphaLuaModule'", + inOneFile.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Theory] + [InlineData("public void Register() { }", "Register")] + [InlineData("public void Unregister(int reason) { }", "Unregister")] + [InlineData("public int Descriptor => 0;", "Descriptor")] + [InlineData("private static readonly int s_descriptor = 0;", "s_descriptor")] + [InlineData("private int _luaRegistration;", "_luaRegistration")] + [InlineData("CheatEngine.Client.Lua.LuaModuleReleaseOutcome ILuaModule.Unregister() => null!;", "ILuaModule.Unregister")] + public void ReservedMemberDeclarationReportsCECLUA1202AndGeneratesNothing(string member, string reportedName) + { + string source = BindingsPrefix + + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"plugin\")]\n" + + "internal sealed partial class PluginLuaModule : ILuaModule\n{\n\t" + member + "\n}\n"; + + GeneratorRun run = GeneratorRun.Execute(source); + + Diagnostic diagnostic = Assert.Single(run.Diagnostics); + Assert.Equal("CECLUA1202", diagnostic.Id); + Assert.Equal( + "Lua module 'TestPlugin.PluginLuaModule' declares '" + reportedName + + "', which is reserved by the generated ownership-aware registration; rename or remove the member", + diagnostic.GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(LineOf(source, member), diagnostic.Location.GetLineSpan().StartLinePosition.Line); + Assert.Empty(run.GeneratedSources); + } + + [Theory] + [InlineData( + "internal abstract class ModuleBase : ILuaModule { public void Register() { } public void Unregister() { } }", + "TestPlugin.ModuleBase")] + [InlineData( + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"base\")] internal partial class GeneratedBase { }", + "TestPlugin.GeneratedBase")] + public void InheritedLuaModuleImplementationReportsCECLUA1203(string baseDeclaration, string baseName) + { + string baseType = baseName[(baseName.LastIndexOf('.') + 1)..]; + string source = BindingsPrefix + baseDeclaration + "\n" + + "internal static partial class DerivedLuaBindings\n{\n\t[LuaFunction(\"derived\")]\n\tpublic static int Derived() => 1;\n}\n" + + "[CheatEngineLuaModule(typeof(DerivedLuaBindings), \"derived\")]\n" + + "internal sealed partial class DerivedLuaModule : " + baseType + ";\n"; + + GeneratorRun run = GeneratorRun.Execute(source); + + Diagnostic diagnostic = Assert.Single(run.Diagnostics); + Assert.Equal("CECLUA1203", diagnostic.Id); + Assert.Equal( + "Lua module 'TestPlugin.DerivedLuaModule' derives from '" + baseName + + "', which already implements a Lua module; the generated ownership state of one of them would be bypassed, so derive the module from object", + diagnostic.GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(LineOf(source, "internal sealed partial class DerivedLuaModule"), + diagnostic.Location.GetLineSpan().StartLinePosition.Line); + Assert.DoesNotContain(run.GeneratedSources, + static generated => generated.HintName.Contains("DerivedLuaModule", StringComparison.Ordinal)); + } + + [Fact] + public void LookAlikeModuleAttributeReportsCECLUA1204() + { + string source = BindingsPrefix.Replace("namespace TestPlugin;", string.Empty, StringComparison.Ordinal) + + """ + namespace CheatEngine.Client.Lua + { + [System.AttributeUsage(System.AttributeTargets.Class)] + internal sealed class CheatEngineLuaModuleAttribute(System.Type bindingsType, string? name = null) : System.Attribute + { + public System.Type BindingsType { get; } = bindingsType; + public string? Name { get; } = name; + } + } + + namespace TestPlugin + { + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + } + """; + + GeneratorRun run = GeneratorRun.Execute(source); + + Diagnostic diagnostic = Assert.Single(run.Diagnostics); + Assert.Equal("CECLUA1204", diagnostic.Id); + Assert.Equal( + "'CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute' is declared in assembly 'CheatEngineClientLuaGeneratorTests' instead of the contract assembly 'CheatEngine.Client.Abstractions'; the annotation is not a Lua module contract and no module code is generated", + diagnostic.GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(LineOf(source, "[CheatEngineLuaModule(typeof(PluginLuaBindings)"), + diagnostic.Location.GetLineSpan().StartLinePosition.Line); + Assert.Empty(run.GeneratedSources); + // The look-alike shadows the contract type (CS0436), which the diagnostic makes actionable. + Assert.Contains(run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken), + static compilerDiagnostic => compilerDiagnostic.Id == "CS0436"); + } + + [Fact] + public void LookAlikeLuaFunctionAttributeReportsCECLUA1204AndIsNotExported() + { + string source = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Annotations.Lua + { + [System.AttributeUsage(System.AttributeTargets.Method)] + internal sealed class LuaFunctionAttribute(string name) : System.Attribute + { + public string Name { get; } = name; + } + } + + namespace TestPlugin + { + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + } + """; + + GeneratorRun run = GeneratorRun.Execute(source); + + Assert.Equal(["CECLUA1204", "CECLUA1204"], run.Diagnostics.Select(static diagnostic => diagnostic.Id)); + Assert.All(run.Diagnostics, static diagnostic => Assert.Contains( + "'CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute' is declared in assembly 'CheatEngineClientLuaGeneratorTests' instead of the contract assembly 'CheatEngine.SDK.Annotations'", + diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal)); + Assert.Equal( + [LineOf(source, "[LuaFunction(\"status\")]"), LineOf(source, "[LuaFunction(\"ping\")]")], + run.Diagnostics.Select(static diagnostic => diagnostic.Location.GetLineSpan().StartLinePosition.Line)); + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void NewModuleDiagnosticsAreLocatedAndDeterministic() + { + string[] sources = + [ + AlphaFile + "\n" + WithoutHeader(BetaFile), + BindingsPrefix + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"plugin\")]\n" + + "internal sealed partial class PluginLuaModule : ILuaModule\n{\n\tpublic void Register() { }\n}\n", + BindingsPrefix + "internal abstract class ModuleBase : ILuaModule { public void Register() { } public void Unregister() { } }\n" + + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"plugin\")]\n" + + "internal sealed partial class PluginLuaModule : ModuleBase;\n" + ]; + string[] expectedIds = ["CECLUA1201", "CECLUA1202", "CECLUA1203"]; + + for (int index = 0; index < sources.Length; index++) + { + Diagnostic first = Assert.Single(GeneratorRun.Execute(sources[index]).Diagnostics); + Diagnostic second = Assert.Single(GeneratorRun.Execute(sources[index]).Diagnostics); + + Assert.Equal(expectedIds[index], first.Id); + Assert.NotEqual(Location.None, first.Location); + Assert.Equal(first.GetMessage(CultureInfo.InvariantCulture), second.GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(first.Location.GetLineSpan(), second.Location.GetLineSpan()); + Assert.Equal(first.Location.SourceSpan, second.Location.SourceSpan); + } + } + + /// Drops the two usings and the file-scoped namespace so a second file can be appended to the first. + private static string WithoutHeader(string source) + { + string[] lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + Assert.StartsWith("namespace TestPlugin;", lines[2], StringComparison.Ordinal); + return string.Join('\n', lines[3..]); + } + + private static int LineOf(string source, string fragment) + { + string normalized = source.Replace("\r\n", "\n", StringComparison.Ordinal); + int index = normalized.IndexOf(fragment, StringComparison.Ordinal); + Assert.True(index >= 0, $"'{fragment}' is not in the source."); + return normalized[..index].Count(static character => character == '\n'); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/FakeLuaGlobals.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/FakeLuaGlobals.cs new file mode 100644 index 0000000..cc016d8 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/FakeLuaGlobals.cs @@ -0,0 +1,453 @@ +using CheatEngine.SDK.Lua.Registration; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + +/// The Lua type a stands for. +public enum FakeLuaType +{ + FunctionValue, + TableValue, + NumberValue, + StringValue, + UserdataValue, + BooleanValue +} + +/// A Lua value stand-in whose identity is its object reference (the double's lua_rawequal). +public sealed class FakeLuaValue(string label, FakeLuaType type = FakeLuaType.FunctionValue) +{ + public string Label + { + get; + } = label; + + public FakeLuaType Type + { + get; + } = type; + + public override string ToString() + { + return Label; + } +} + +/// The registration lease the double hands out, standing for an SDK LuaRegistrationLease. +public sealed class FakeLease +{ + internal FakeLease(int identity, IReadOnlyList<(string Name, FakeLuaValue Installed)> entries) + { + Identity = identity; + Entries = entries; + } + + /// Gets the attachment and Lua state identity captured at publication. + public int Identity + { + get; + } + + /// Gets whether ownership was consumed by a release. + public bool IsConsumed + { + get; + internal set; + } + + internal IReadOnlyList<(string Name, FakeLuaValue Installed)> Entries + { + get; + } +} + +/// What the double reports for one release, with the fields of the SDK LuaRegistrationReleaseOutcome. +public sealed record FakeRelease( + LuaRegistrationReleaseKind Kind, + int RemovedCount, + int RestoredCount, + int ReplacementCount, + int RemainingCount, + string[] FailedExports); + +/// What the double reports for one registration, with the fields of the SDK LuaRegistrationResult. +public sealed record FakePublication( + LuaRegistrationResultKind Kind, + FakeLease? Lease, + string? FailedExport, + string? FailedStatus, + FakeRelease Rollback); + +/// +/// Managed double of the CheatEngine.SDK 2.0.0 Lua admission and registration set (LuaRuntime, +/// LuaRegistrationSet and LuaRegistrationLease) over a Lua global table, called one SDK call at a time by +/// the replacement adapter of . It follows the SDK source step by step: a +/// RejectExisting preflight that reads every global, a publication that installs one function per export, a +/// compensation that releases what a failed publication installed, and an ownership-aware release that compares each +/// global with the installed value by identity and writes only while it is still the installed one. Failures are +/// injected per global name; every read and write is logged. +/// +/// +/// Log entries are read:<name>, write:<name> (a publication), clear:<name> (a +/// release that wrote nil), and the failed variants read-failed, write-failed and +/// clear-failed. A stale or already-consumed lease makes no Lua operation and logs nothing. Third-party +/// assignments made by a test are not logged. Admitted operations are counted apart from the log. This is C1 +/// evidence of the Client code around the adapter, never of the SDK itself. +/// +public sealed class FakeLuaGlobals +{ + [ThreadStatic] + private static FakeLuaGlobals? _current; + + private readonly List _bindingsRegistrations = []; + private readonly Dictionary _globals = new(StringComparer.Ordinal); + private readonly List _log = []; + + public FakeLuaGlobals(string moduleName, IReadOnlyList exports) + { + ModuleName = moduleName; + Exports = exports; + } + + /// Gets the double the replacement adapter uses on this thread. + public static FakeLuaGlobals Current => + _current ?? throw new InvalidOperationException("No FakeLuaGlobals is active on this thread."); + + public string ModuleName + { + get; + } + + /// Gets the globals the SDK-generated registration of the module publishes, in registration order. + public IReadOnlyList Exports + { + get; + } + + /// Gets the attachment and Lua state identity; a lease captured under another one is stale. + public int Identity + { + get; + private set; + } = 1; + + /// Gets or sets the admission CheatEngine.SDK grants to the next Lua operation. + public LuaAdmissionStatus Admission + { + get; + set; + } = LuaAdmissionStatus.Admitted; + + public IReadOnlyList Log => _log; + + /// Gets how many Lua operations CheatEngine.SDK admitted. + public int AdmittedOperationCount + { + get; + private set; + } + + /// Gets how many admitted Lua operations have not been ended. + public int OpenOperationCount + { + get; + private set; + } + + /// Gets the collision policy of every call the module made to its bindings' registration. + public IReadOnlyList BindingsRegistrations => _bindingsRegistrations; + + /// Gets how many leases were consumed without a Lua state (the parameterless SDK release). + public int StaleReleaseCount + { + get; + private set; + } + + /// Gets the globals whose protected read fails with a Lua error. + public HashSet FailReads + { + get; + } = new(StringComparer.Ordinal); + + /// Gets the globals whose publication (the protected assignment of the module's function) fails. + public HashSet FailPublications + { + get; + } = new(StringComparer.Ordinal); + + /// Gets the globals whose release (the protected assignment of nil) fails, every time. + public HashSet FailClears + { + get; + } = new(StringComparer.Ordinal); + + /// Gets the globals whose next release fails once; a later release of the same global succeeds. + public HashSet FailClearsOnce + { + get; + } = new(StringComparer.Ordinal); + + /// + /// Gets or sets what the next release with a state reports instead of releasing: it consumes the lease, writes + /// nothing, and returns this value once (a result outside the documented shape, or a kind a later SDK adds). + /// + public FakeRelease? NextRelease + { + get; + set; + } + + /// + /// Gets or sets what the next registration reports instead of publishing: it reads and writes nothing, and + /// returns this value once (a result outside the documented shape, or a kind a later SDK adds). + /// + public FakePublication? NextPublication + { + get; + set; + } + + /// Gets the current value of a global, or for nil. + public FakeLuaValue? this[string name] => _globals.GetValueOrDefault(name); + + /// Makes this double the one the replacement adapter uses on this thread until the scope is disposed. + public IDisposable Activate() + { + FakeLuaGlobals? previous = _current; + _current = this; + return new Scope(previous); + } + + /// Assigns a global as a third party (a script, a table, another plugin); not logged. + public void AssignByThirdParty(string name, FakeLuaValue? value) + { + if (value is null) + { + _globals.Remove(name); + } + else + { + _globals[name] = value; + } + } + + /// Replaces the Lua state: every global is gone and every lease becomes stale. + public void ReplaceLuaState() + { + _globals.Clear(); + Identity++; + } + + /// + /// Disables and re-enables the plugin on the same Cheat Engine Lua state: the globals stay, but every lease belongs + /// to the earlier attachment. + /// + public void Reattach() + { + Identity++; + } + + public void ClearLog() + { + _log.Clear(); + } + + public int CountOf(string prefix) + { + return _log.Count(entry => entry.StartsWith(prefix, StringComparison.Ordinal)); + } + + /// Records a call of the harness module's bindings registration (the stand-in for the SDK-generated one). + public void RecordBindingsRegistration(LuaRegistrationCollisionPolicy collisionPolicy) + { + RequireOpenOperation(); + _bindingsRegistrations.Add(collisionPolicy); + } + + // ----- SDK calls: made only by the replacement adapter, one adapter member each. ----- + + /// LuaRuntime.TryAcquireOperationWithOutcome: grants . + public LuaAdmissionStatus Admit() + { + if (Admission == LuaAdmissionStatus.Admitted) + { + AdmittedOperationCount++; + OpenOperationCount++; + } + + return Admission; + } + + /// LuaRuntimeOperation.Dispose of an admitted operation. + public void EndOperation() + { + RequireOpenOperation(); + OpenOperationCount--; + } + + /// The result of TryRegisterLuaFunctions(state, RejectExisting) in an admitted operation. + public FakePublication Publish() + { + RequireOpenOperation(); + if (NextPublication is { } scripted) + { + NextPublication = null; + return scripted; + } + + for (int index = 0; index < Exports.Count; index++) + { + string name = Exports[index]; + if (!TryRead(name, out FakeLuaValue? existing)) + { + return new FakePublication(LuaRegistrationResultKind.PreflightFailed, null, name, "LUA_ERRRUN", + NotAttempted()); + } + + if (existing is not null) + { + return new FakePublication(LuaRegistrationResultKind.Collision, null, name, "LUA_OK", NotAttempted()); + } + } + + List<(string Name, FakeLuaValue Installed)> installed = []; + foreach (string name in Exports) + { + FakeLuaValue function = new(ModuleName + ":" + name); + // The SDK creates the installed reference before the protected assignment, so a failed write is compensated too. + installed.Add((name, function)); + if (FailPublications.Contains(name)) + { + _log.Add("write-failed:" + name); + (FakeRelease rollback, List<(string Name, FakeLuaValue Installed)> residual) = ReleaseEntries(installed, true); + FakeLease? residualLease = residual.Count == 0 ? null : new FakeLease(Identity, residual); + return new FakePublication(LuaRegistrationResultKind.PublicationFailed, residualLease, name, "LUA_ERRRUN", + rollback); + } + + _log.Add("write:" + name); + _globals[name] = function; + } + + return new FakePublication(LuaRegistrationResultKind.Succeeded, new FakeLease(Identity, installed), null, null, + NotAttempted()); + } + + /// LuaRegistrationLease.ReleaseWithOutcome(state) in an admitted operation. + public FakeRelease Release(FakeLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + RequireOpenOperation(); + if (lease.IsConsumed) + { + return new FakeRelease(LuaRegistrationReleaseKind.AlreadyReleased, 0, 0, 0, 0, []); + } + + lease.IsConsumed = true; + if (NextRelease is { } scripted) + { + NextRelease = null; + return scripted; + } + + if (lease.Identity != Identity) + { + return new FakeRelease(LuaRegistrationReleaseKind.Stale, 0, 0, 0, lease.Entries.Count, []); + } + + return ReleaseEntries(lease.Entries, false).Outcome; + } + + /// LuaRegistrationLease.ReleaseWithOutcome() without an admitted state: the SDK reports it stale. + public FakeRelease ReleaseStale(FakeLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (lease.IsConsumed) + { + return new FakeRelease(LuaRegistrationReleaseKind.AlreadyReleased, 0, 0, 0, 0, []); + } + + lease.IsConsumed = true; + StaleReleaseCount++; + return new FakeRelease(LuaRegistrationReleaseKind.Stale, 0, 0, 0, lease.Entries.Count, []); + } + + private static FakeRelease NotAttempted() + { + return new FakeRelease(LuaRegistrationReleaseKind.NotAttempted, 0, 0, 0, 0, []); + } + + private void RequireOpenOperation() + { + if (OpenOperationCount == 0) + { + throw new InvalidOperationException("CheatEngine.SDK Lua work requires an admitted operation."); + } + } + + private (FakeRelease Outcome, List<(string Name, FakeLuaValue Installed)> Residual) ReleaseEntries( + IReadOnlyList<(string Name, FakeLuaValue Installed)> entries, bool retainFailures) + { + List failed = []; + List<(string Name, FakeLuaValue Installed)> residual = []; + int removed = 0; + int replaced = 0; + foreach ((string name, FakeLuaValue installed) in entries) + { + if (!TryRead(name, out FakeLuaValue? current)) + { + failed.Add(name); + residual.Add((name, installed)); + continue; + } + + if (!ReferenceEquals(current, installed)) + { + // A replacement, a wrapper, or nil: never the installed value, so nothing is written. + replaced++; + continue; + } + + if (FailClears.Contains(name) || FailClearsOnce.Remove(name)) + { + _log.Add("clear-failed:" + name); + failed.Add(name); + residual.Add((name, installed)); + continue; + } + + _log.Add("clear:" + name); + _globals.Remove(name); + removed++; + } + + LuaRegistrationReleaseKind kind = failed.Count == 0 + ? LuaRegistrationReleaseKind.Released + : LuaRegistrationReleaseKind.PartiallyReleased; + return (new FakeRelease(kind, removed, 0, replaced, failed.Count, [.. failed]), + retainFailures ? residual : []); + } + + private bool TryRead(string name, out FakeLuaValue? value) + { + if (FailReads.Contains(name)) + { + _log.Add("read-failed:" + name); + value = null; + return false; + } + + _log.Add("read:" + name); + value = _globals.GetValueOrDefault(name); + return true; + } + + private sealed class Scope(FakeLuaGlobals? previous) : IDisposable + { + public void Dispose() + { + _current = previous; + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/GeneratedRegistrarMappingTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/GeneratedRegistrarMappingTests.cs new file mode 100644 index 0000000..a52eedd --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/GeneratedRegistrarMappingTests.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using System.Reflection; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Registration; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + +/// +/// Totality of the mappings the generated registrar applies to CheatEngine.SDK outcome enums: every value the +/// consumed SDK defines reaches its dedicated Client value, and a value a later SDK could add fails closed. The +/// mappings are read from the real generated registrar of the EndToEnd harness, never from a copy. +/// +public sealed class GeneratedRegistrarMappingTests +{ + // The registrar maps only the release of a lease it has consumed, so no kind may be retryable. + private static readonly Dictionary ReleaseKinds = new() + { + // The SDK's value before any release: outside the result of one, and the lease is consumed all the same. + [LuaRegistrationReleaseKind.NotAttempted] = LeaseReleaseKind.CleanupUnconfirmed, + [LuaRegistrationReleaseKind.Released] = LeaseReleaseKind.Released, + [LuaRegistrationReleaseKind.PartiallyReleased] = LeaseReleaseKind.PartiallyReleased, + // The SDK counts Stale as complete (no cleanup call failed), but it reports every entry as remaining: a release + // after re-enable leaves the earlier attachment's functions in the same Lua state. RequiresManualRecovery holds. + [LuaRegistrationReleaseKind.Stale] = LeaseReleaseKind.RefusedRuntimeChanged, + [LuaRegistrationReleaseKind.AlreadyReleased] = LeaseReleaseKind.AlreadyReleased + }; + + private static readonly Dictionary Admissions = new() + { + [LuaAdmissionStatus.Unknown] = CheatEngineFailureKind.IndeterminateHostResult, + [LuaAdmissionStatus.Detached] = CheatEngineFailureKind.ActivationExpired, + [LuaAdmissionStatus.TransitionInProgress] = CheatEngineFailureKind.ActivationExpired, + [LuaAdmissionStatus.NoStateForThread] = CheatEngineFailureKind.InvalidState, + [LuaAdmissionStatus.ThreadNotAdmitted] = CheatEngineFailureKind.InvalidState, + [LuaAdmissionStatus.ExternalStateReset] = CheatEngineFailureKind.RuntimeChanged + }; + + private static readonly Dictionary ResultKinds = new() + { + [LuaRegistrationResultKind.Unspecified] = CheatEngineFailureKind.IndeterminateHostResult, + // Reached only for a success that carries no registration lease. + [LuaRegistrationResultKind.Succeeded] = CheatEngineFailureKind.IndeterminateHostResult, + [LuaRegistrationResultKind.Collision] = CheatEngineFailureKind.OperationRejected, + [LuaRegistrationResultKind.PreflightFailed] = CheatEngineFailureKind.LuaError, + [LuaRegistrationResultKind.PublicationFailed] = CheatEngineFailureKind.LuaError + }; + + private static ModuleHarness Harness => ModuleHarness.Shared; + + [Fact] + public void EveryLuaRegistrationReleaseKindIsMappedAndAnUnknownKindFailsClosed() + { + AssertTotal(ReleaseKinds, "MapReleaseKind", LeaseReleaseKind.CleanupUnconfirmed); + Assert.True(new LeaseReleaseOutcome(LeaseReleaseKind.RefusedRuntimeChanged, CheatEngineHostEffect.NotStarted) + .RequiresManualRecovery); + Assert.True(new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, CheatEngineHostEffect.Started) + .RequiresManualRecovery); + Assert.DoesNotContain(ReleaseKinds.Values, static kind => kind is LeaseReleaseKind.Unknown + or LeaseReleaseKind.CleanupUnavailable); + } + + [Fact] + public void EveryRefusedLuaAdmissionIsClassifiedAndAnUnknownStatusFailsClosed() + { + AssertTotal(Admissions, "MapAdmission", CheatEngineFailureKind.IndeterminateHostResult, + LuaAdmissionStatus.Admitted); + } + + [Fact] + public void EveryLuaRegistrationResultKindIsClassifiedAndAnUnknownKindFailsClosed() + { + AssertTotal(ResultKinds, "MapResultKind", CheatEngineFailureKind.IndeterminateHostResult); + } + + private static void AssertTotal(Dictionary expected, string method, + TClient fallback, params TSdk[] notMapped) + where TSdk : struct, Enum + { + TSdk[] values = [.. Enum.GetValues().Except(notMapped)]; + Assert.Equal(values.Order(), expected.Keys.Order()); + foreach (TSdk value in values) + { + Assert.Equal(expected[value], Harness.InvokeRegistrar(method, value)); + } + + long largest = Enum.GetValues().Select(static value => Convert.ToInt64(value, CultureInfo.InvariantCulture)) + .Max(); + TSdk undefined = (TSdk) Enum.ToObject(typeof(TSdk), largest + 1); + Assert.False(Enum.IsDefined(undefined)); + Assert.Equal(fallback, Harness.InvokeRegistrar(method, undefined)); + Assert.NotNull(Harness.RegistrarType.GetMethod(method, BindingFlags.NonPublic | BindingFlags.Static)); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/LuaModuleOwnershipEndToEndTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/LuaModuleOwnershipEndToEndTests.cs new file mode 100644 index 0000000..22f6745 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/LuaModuleOwnershipEndToEndTests.cs @@ -0,0 +1,553 @@ +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Registration; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + +/// +/// C1 execution of the generated module and registrar around the CheatEngine.SDK registration leases (F12, Q16): the +/// module's public Register/Unregister and the generated registrar run unchanged; only the generated SDK +/// adapter is replaced by one that forwards each SDK call to a managed double of the SDK registration set. This is not +/// a Lua fixture (C2) and not a host observation (C3/C4). +/// +/// +/// A function a script kept after disable is not tested here: that CheatEngine.SDK 2.0.0 closure (CRIT-07) runs +/// inside the SDK registration set, which these tests replace. Its evidence is the composition test (the module +/// publishes through LuaRegistrationSet.Register) and the Q16 host scenario. +/// +[Trait("Qualification", "Q16")] +public sealed class LuaModuleOwnershipEndToEndTests +{ + private const string Status = "status"; + private const string Ping = "ping"; + private const string Marker = "marker"; + + private static ModuleHarness Harness => ModuleHarness.Shared; + + [Fact] + public void RegisterPublishesEveryExportAfterAPreflightThatWritesNothing() + { + (_, FakeLuaGlobals globals) = Registered(); + + Assert.All(ModuleHarness.Exports, export => Assert.NotNull(globals[export])); + } + + [Fact] + public void UnregisterRemovesEveryExportTheModuleStillOwns() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + AssertOutcome(outcome, LeaseReleaseKind.Released, 3, 0, 0); + Assert.True(outcome.IsComplete); + Assert.All(ModuleHarness.Exports, export => Assert.Null(globals[export])); + Assert.Equal(["read:status", "clear:status", "read:ping", "clear:ping", "read:marker", "clear:marker"], + globals.Log); + } + + [Fact] + public void UnregisterLeavesAThirdPartyReplacementUntouched() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + FakeLuaValue thirdParty = new("third-party:ping"); + globals.AssignByThirdParty(Ping, thirdParty); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + AssertOutcome(outcome, LeaseReleaseKind.Released, 2, 1, 0); + Assert.Same(thirdParty, globals[Ping]); + Assert.Equal(0, globals.CountOf("clear:ping")); + Assert.Null(globals[Status]); + Assert.Null(globals[Marker]); + } + + [Fact] + public void UnregisterTreatsAWrappedFunctionAsAReplacement() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + // A wrapper that would call the module's function is still a different value: ownership is identity, not behaviour. + FakeLuaValue wrapper = new("function(...) return plugin_ping(...) end"); + globals.AssignByThirdParty(Ping, wrapper); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + Assert.Equal(1, outcome.ReplacementCount); + Assert.Same(wrapper, globals[Ping]); + Assert.Equal(0, globals.CountOf("clear:ping")); + } + + [Fact] + public void UnregisterRemovesAValueAThirdPartyRestoredToTheModulesOwn() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + FakeLuaValue original = globals[Ping]!; + globals.AssignByThirdParty(Ping, new FakeLuaValue("third-party:ping")); + globals.AssignByThirdParty(Ping, original); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + AssertOutcome(outcome, LeaseReleaseKind.Released, 3, 0, 0); + Assert.Null(globals[Ping]); + } + + [Fact] + public void UnregisterCountsAnExportThatIsAlreadyAbsentAsAReplacementAndWritesNothing() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.AssignByThirdParty(Marker, null); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + // CheatEngine.SDK compares nil with the installed value like any other value: it is not the module's any more. + AssertOutcome(outcome, LeaseReleaseKind.Released, 2, 1, 0); + Assert.Equal(0, globals.CountOf("clear:marker")); + } + + [Theory] + [InlineData(FakeLuaType.TableValue)] + [InlineData(FakeLuaType.NumberValue)] + [InlineData(FakeLuaType.StringValue)] + [InlineData(FakeLuaType.UserdataValue)] + [InlineData(FakeLuaType.BooleanValue)] + [InlineData(FakeLuaType.FunctionValue)] + public void ThirdPartyValuesOfAnyLuaTypeAreCountedAsReplacements(FakeLuaType type) + { + // The double compares by object identity: this proves that the Client code keeps any value the SDK reports as + // replaced, not how lua_rawequal compares strings, numbers or light C functions (that needs a Lua state, C2). + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + FakeLuaValue thirdParty = new("third-party:" + type, type); + globals.AssignByThirdParty(Status, thirdParty); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + Assert.Equal(1, outcome.ReplacementCount); + Assert.Same(thirdParty, globals[Status]); + } + + [Fact] + public void UnregisterAfterALuaStateReplacementWritesNothingAndReportsRefusedRuntimeChanged() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.ReplaceLuaState(); + FakeLuaValue newStateValue = new("new-state:status"); + globals.AssignByThirdParty(Status, newStateValue); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + AssertOutcome(outcome, LeaseReleaseKind.RefusedRuntimeChanged, 0, 0, 3); + Assert.False(outcome.IsComplete); + Assert.Empty(globals.Log); + Assert.Same(newStateValue, globals[Status]); + } + + [Fact] + public void UnregisterAfterAReattachWritesNothingAndLeavesTheEarlierGlobalsInPlace() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.Reattach(); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + // The earlier attachment's functions may remain: this is why the kind requires manual recovery, not ExternallyRemoved. + AssertOutcome(outcome, LeaseReleaseKind.RefusedRuntimeChanged, 0, 0, 3); + Assert.Empty(globals.Log); + Assert.All(ModuleHarness.Exports, export => Assert.NotNull(globals[export])); + } + + [Theory] + [InlineData(LuaAdmissionStatus.Detached)] + [InlineData(LuaAdmissionStatus.ExternalStateReset)] + public void UnregisterWithoutTheLuaUniverseConsumesTheRegistrationAsStaleWithoutLua(LuaAdmissionStatus admission) + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.Admission = admission; + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + LuaModuleReleaseOutcome retry = ModuleHarness.Unregister(module, globals); + + AssertOutcome(outcome, LeaseReleaseKind.RefusedRuntimeChanged, 0, 0, 3); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, retry.Kind); + Assert.Equal(1, globals.StaleReleaseCount); + Assert.Empty(globals.Log); + } + + [Theory] + [InlineData(LuaAdmissionStatus.TransitionInProgress)] + [InlineData(LuaAdmissionStatus.ThreadNotAdmitted)] + [InlineData(LuaAdmissionStatus.NoStateForThread)] + [InlineData(LuaAdmissionStatus.Unknown)] + public void UnregisterWithoutAdmissionKeepsTheRegistrationForALaterAttempt(LuaAdmissionStatus admission) + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.Admission = admission; + + LuaModuleReleaseOutcome refused = ModuleHarness.Unregister(module, globals); + globals.Admission = LuaAdmissionStatus.Admitted; + LuaModuleReleaseOutcome retried = ModuleHarness.Unregister(module, globals); + + AssertOutcome(refused, LeaseReleaseKind.CleanupUnavailable, 0, 0, 3); + Assert.False(refused.IsComplete); + AssertOutcome(retried, LeaseReleaseKind.Released, 3, 0, 0); + } + + [Fact] + public void UnregisterReportsIndependentFailuresAsAPartialReleaseThatIsNeverRetried() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.FailReads.Add(Status); + globals.FailClears.Add(Ping); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + globals.ClearLog(); + LuaModuleReleaseOutcome retry = ModuleHarness.Unregister(module, globals); + + Assert.Equal(LeaseReleaseKind.PartiallyReleased, outcome.Kind); + Assert.Equal([Status, Ping], outcome.FailedExports); + Assert.Equal(1, outcome.RemovedCount); + Assert.Equal(2, outcome.RemainingCount); + Assert.False(outcome.IsComplete); + Assert.Null(globals[Marker]); + Assert.NotNull(globals[Status]); + Assert.NotNull(globals[Ping]); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, retry.Kind); + Assert.Empty(globals.Log); + } + + [Theory] + [InlineData(LuaRegistrationReleaseKind.NotAttempted)] + [InlineData(LuaRegistrationReleaseKind.PartiallyReleased)] + [InlineData((LuaRegistrationReleaseKind) 99)] + public void AReleaseOutsideTheSdkShapeIsAnUnconfirmedCleanupThatIsNeverRetried(LuaRegistrationReleaseKind reported) + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + // A kind a later CheatEngine.SDK adds, the SDK's pre-release value, or a partial release that names no global. + globals.NextRelease = new FakeRelease(reported, 1, 0, 0, 0, []); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + LuaModuleReleaseOutcome retry = ModuleHarness.Unregister(module, globals); + + // The lease was consumed before the release ran: the outcome requires manual recovery and is never retryable, so + // the Client lease ends and the activation cleanup reports it instead of reading the retry as a clean release. + AssertOutcome(outcome, LeaseReleaseKind.CleanupUnconfirmed, 1, 0, 2); + Assert.False(outcome.IsComplete); + LeaseReleaseOutcome lease = new(outcome.Kind, CheatEngineHostEffect.Started); + Assert.True(lease.RequiresManualRecovery); + Assert.False(lease.IsRetryable); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, retry.Kind); + Assert.Equal(2, globals.AdmittedOperationCount); + } + + [Fact] + public void UnregisterWithoutARegistrationReportsAlreadyReleasedWithoutLua() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + Assert.Equal(LeaseReleaseKind.AlreadyReleased, outcome.Kind); + Assert.Equal(ModuleHarness.ModuleName, outcome.ModuleName); + Assert.True(outcome.IsComplete); + Assert.Empty(globals.Log); + } + + [Fact] + public void RegisterRefusesAnOccupiedExportBeforeAnyWrite() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + FakeLuaValue existing = new("third-party:marker"); + globals.AssignByThirdParty(Marker, existing); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Equal("Lua global 'marker' is already defined and cannot be replaced by Client module 'plugin'.", + failure.Message); + Assert.Equal(["read:status", "read:ping", "read:marker"], globals.Log); + Assert.Same(existing, globals[Marker]); + Assert.Null(globals[Status]); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, ModuleHarness.Unregister(module, globals).Kind); + } + + [Fact] + public void RegisterReportsAPreflightReadFailureBeforeAnyWrite() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.FailReads.Add(Ping); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.Contains("could not read Lua global 'ping'", failure.Message, StringComparison.Ordinal); + Assert.Contains("LUA_ERRRUN", failure.Message, StringComparison.Ordinal); + Assert.Equal(["read:status", "read-failed:ping"], globals.Log); + } + + [Fact] + public void RegisterRollsBackWhatItPublishedWhenPublicationFails() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.FailPublications.Add(Ping); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.StartsWith("Client Lua module 'plugin' could not publish Lua global 'ping' (LUA_ERRRUN). The rollback " + + "reported Released (removed 1, replaced 1, remaining 0).", failure.Message, StringComparison.Ordinal); + Assert.Equal( + ["read:status", "read:ping", "read:marker", "write:status", "write-failed:ping", "read:status", "clear:status", + "read:ping"], + globals.Log); + Assert.All(ModuleHarness.Exports, export => Assert.Null(globals[export])); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, ModuleHarness.Unregister(module, globals).Kind); + } + + [Fact] + public void RegisterReportsARollbackCompletedByTheResidualReleaseAsNotApplied() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.FailPublications.Add(Marker); + globals.FailClearsOnce.Add(Ping); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + // The SDK compensation could not clear ping and kept it in a residual lease; the generated registrar released that + // residual lease once more, inside the same admitted operation, and it succeeded. + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.Contains("The rollback reported PartiallyReleased (removed 1, replaced 1, remaining 1, failed ping).", + failure.Message, StringComparison.Ordinal); + Assert.Contains("The release of what the rollback left reported Released (removed 1, replaced 0, remaining 0).", + failure.Message, StringComparison.Ordinal); + Assert.All(ModuleHarness.Exports, export => Assert.Null(globals[export])); + } + + [Fact] + public void RegisterReportsARollbackThatLeftAGlobalAsAnUnconfirmedCleanup() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.FailPublications.Add(Marker); + globals.FailClears.Add(Ping); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Contains("The release of what the rollback left reported PartiallyReleased (removed 0, replaced 0, " + + "remaining 1, failed ping).", failure.Message, StringComparison.Ordinal); + Assert.NotNull(globals[Ping]); + Assert.Null(globals[Status]); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, ModuleHarness.Unregister(module, globals).Kind); + } + + [Theory] + [InlineData(LuaRegistrationResultKind.Succeeded, CheatEngineHostEffect.CleanupUnconfirmed)] + [InlineData(LuaRegistrationResultKind.Unspecified, CheatEngineHostEffect.Unknown)] + [InlineData((LuaRegistrationResultKind) 99, CheatEngineHostEffect.Unknown)] + public void RegisterFailsClosedOnAResultWithoutALease(LuaRegistrationResultKind reported, + CheatEngineHostEffect expectedEffect) + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.NextPublication = new FakePublication(reported, null, null, null, + new FakeRelease(LuaRegistrationReleaseKind.NotAttempted, 0, 0, 0, 0, [])); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + // A success without its lease may have published globals that no lease can remove. + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(expectedEffect, failure.HostEffect); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Equal(0, globals.OpenOperationCount); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, ModuleHarness.Unregister(module, globals).Kind); + } + + [Theory] + [InlineData(LuaAdmissionStatus.Detached, CheatEngineFailureKind.ActivationExpired)] + [InlineData(LuaAdmissionStatus.TransitionInProgress, CheatEngineFailureKind.ActivationExpired)] + [InlineData(LuaAdmissionStatus.ExternalStateReset, CheatEngineFailureKind.RuntimeChanged)] + [InlineData(LuaAdmissionStatus.ThreadNotAdmitted, CheatEngineFailureKind.InvalidState)] + [InlineData(LuaAdmissionStatus.NoStateForThread, CheatEngineFailureKind.InvalidState)] + [InlineData(LuaAdmissionStatus.Unknown, CheatEngineFailureKind.IndeterminateHostResult)] + [InlineData((LuaAdmissionStatus) 99, CheatEngineFailureKind.IndeterminateHostResult)] + public void RegisterWithoutAdmissionIsRefusedBeforeAnyLuaCall(LuaAdmissionStatus admission, + CheatEngineFailureKind expected) + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.Admission = admission; + + CheatEngineFailure failure = RegisterFailure(module, globals); + + Assert.Equal(expected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, failure.HostEffect); + Assert.Contains(admission.ToString(), failure.Message, StringComparison.Ordinal); + Assert.Empty(globals.Log); + } + + [Fact] + public void RegisterAgainReleasesTheEarlierRegistrationFirst() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.ReplaceLuaState(); + globals.ClearLog(); + + ModuleHarness.Register(module, globals); + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + // The earlier lease is stale in the new state: it is forgotten without a Lua operation, then the exports are + // published again and released by the new lease. + Assert.Equal( + ["read:status", "read:ping", "read:marker", "write:status", "write:ping", "write:marker", "read:status", + "clear:status", "read:ping", "clear:ping", "read:marker", "clear:marker"], + globals.Log); + AssertOutcome(outcome, LeaseReleaseKind.Released, 3, 0, 0); + } + + [Fact] + public void RegisterAgainPublishesNothingWhenTheEarlierReleaseLeftAGlobal() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + FakeLuaValue kept = globals[Ping]!; + globals.FailClears.Add(Ping); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + // The module's own leftover function is not reported as a third party's global: the partial release is the failure. + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal("Client Lua module 'plugin' could not release its earlier registration before registering again; " + + "nothing was published. The release reported PartiallyReleased (removed 2, replaced 0, remaining 1, " + + "failed ping).", failure.Message); + Assert.Equal(0, globals.CountOf("write")); + Assert.Same(kept, globals[Ping]); + Assert.Equal(0, globals.OpenOperationCount); + // The earlier lease was consumed by that release: nothing is left for Unregister to retry. + Assert.Equal(LeaseReleaseKind.AlreadyReleased, ModuleHarness.Unregister(module, globals).Kind); + } + + [Theory] + [InlineData(LuaRegistrationReleaseKind.NotAttempted)] + [InlineData((LuaRegistrationReleaseKind) 99)] + public void RegisterAgainPublishesNothingWhenTheEarlierReleaseIsNotUnderstood(LuaRegistrationReleaseKind reported) + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.NextRelease = new FakeRelease(reported, 0, 0, 0, 3, []); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + // A release CheatEngine.SDK reports outside its documented shape is not confirmed: nothing is published. + Assert.Equal(CheatEngineFailureKind.IndeterminateHostResult, failure.Kind); + Assert.Equal(CheatEngineHostEffect.CleanupUnconfirmed, failure.HostEffect); + Assert.Equal(0, globals.CountOf("write")); + Assert.Equal(0, globals.OpenOperationCount); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, ModuleHarness.Unregister(module, globals).Kind); + } + + [Fact] + public void RegisterAgainAfterAReattachNamesTheStaleReleaseInTheCollision() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.Reattach(); + + CheatEngineFailure failure = RegisterFailure(module, globals); + + // The earlier attachment's functions stay in the same Lua state: the collision is with the module's own globals. + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotApplied, failure.HostEffect); + Assert.Equal("Lua global 'status' is already defined and cannot be replaced by Client module 'plugin'. The " + + "release of the module's earlier registration reported Stale (removed 0, replaced 0, remaining 3).", + failure.Message); + Assert.Equal(["read:status"], globals.Log); + } + + [Fact] + public void ARefusedRegistrationKeepsTheRegistrationTheModuleAlreadyOwned() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + globals.Admission = LuaAdmissionStatus.TransitionInProgress; + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, RegisterFailure(module, globals).Kind); + globals.Admission = LuaAdmissionStatus.Admitted; + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + + AssertOutcome(outcome, LeaseReleaseKind.Released, 3, 0, 0); + } + + [Fact] + public void RegisterAndUnregisterEachRunInOneAdmittedOperationThatTheyEnd() + { + (ILuaModule module, FakeLuaGlobals globals) = Registered(); + + LuaModuleReleaseOutcome outcome = ModuleHarness.Unregister(module, globals); + globals.Admission = LuaAdmissionStatus.Detached; + _ = RegisterFailure(Harness.CreateModule(), globals); + + Assert.Equal(LeaseReleaseKind.Released, outcome.Kind); + // The registration, then the release; the refused registration held no operation. + Assert.Equal(2, globals.AdmittedOperationCount); + Assert.Equal(0, globals.OpenOperationCount); + // The module handed the registrar its bindings' registration with the RejectExisting policy, once. + Assert.Equal([LuaRegistrationCollisionPolicy.RejectExisting], globals.BindingsRegistrations); + } + + [Fact] + public void AFailedPublicationEndsItsOperationAfterReleasingTheResidualLease() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + globals.FailPublications.Add(Marker); + globals.FailClearsOnce.Add(Ping); + + _ = RegisterFailure(module, globals); + + // The rollback, the residual release and the publication share one admitted operation, ended before the throw. + Assert.Equal(1, globals.AdmittedOperationCount); + Assert.Equal(0, globals.OpenOperationCount); + Assert.Equal(["read:ping", "clear:ping"], globals.Log.TakeLast(2)); + } + + private static (ILuaModule Module, FakeLuaGlobals Globals) Registered() + { + ILuaModule module = Harness.CreateModule(); + FakeLuaGlobals globals = ModuleHarness.CreateGlobals(); + ModuleHarness.Register(module, globals); + Assert.Equal( + ["read:status", "read:ping", "read:marker", "write:status", "write:ping", "write:marker"], + globals.Log); + Assert.Equal(0, globals.OpenOperationCount); + globals.ClearLog(); + return (module, globals); + } + + private static CheatEngineFailure RegisterFailure(ILuaModule module, FakeLuaGlobals globals) + { + // The exception type follows the failure kind (CheatEngineFailure.ToException); the failure is the contract. + CheatEngineClientException exception = + Assert.ThrowsAny(() => ModuleHarness.Register(module, globals)); + return exception.Failure; + } + + private static void AssertOutcome(LuaModuleReleaseOutcome outcome, LeaseReleaseKind kind, int removed, + int replaced, int remaining) + { + Assert.Equal(ModuleHarness.ModuleName, outcome.ModuleName); + Assert.Equal(kind, outcome.Kind); + Assert.Equal(removed, outcome.RemovedCount); + Assert.Equal(0, outcome.RestoredCount); + Assert.Equal(replaced, outcome.ReplacementCount); + Assert.Equal(remaining, outcome.RemainingCount); + Assert.Empty(outcome.FailedExports); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/ModuleHarness.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/ModuleHarness.cs new file mode 100644 index 0000000..3783a78 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/ModuleHarness.cs @@ -0,0 +1,200 @@ +using System.Reflection; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + +/// +/// Compiles a real generated module and the real generated registrar, replaces only the generated CheatEngine.SDK +/// adapter (CheatEngineLuaRegistrationAdapter) with one that forwards each of its SDK calls to +/// , then loads the result and calls the module's public Register and +/// Unregister (C1: every generated decision runs, only the SDK calls are doubles). +/// +/// +/// The adapter is replaceable because it is emitted as a file of its own and holds every SDK call, one call per +/// member and no decision: the SDK LuaRegistrationLease has no public constructor, so no test can hand the real +/// adapter a lease. The replacement keeps that shape and adds no logic, so the tests observe the registrar itself. +/// What the real adapter calls is pinned by GeneratedLuaSurfaceRatchetTests (C0) and compiled against the +/// real SDK generator by the composition test; the host behaviour is the Q16 scenario. +/// +internal sealed class ModuleHarness +{ + internal const string ModuleName = "plugin"; + + /// Three exports, in descriptor order. + internal static readonly string[] Exports = ["status", "ping", "marker"]; + + internal const string ModuleSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Registration; + using CheatEngine.SDK.Lua.State; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + + [LuaFunction("marker")] + public static string Marker() => "marker"; + + // Stands for the SDK LuaBindings output, which the Client generator cannot see in a test compilation: it records + // the call, and the double's registration set supplies the result. + public static LuaRegistrationResult TryRegisterLuaFunctions(LuaState state, + LuaRegistrationCollisionPolicy collisionPolicy = LuaRegistrationCollisionPolicy.RejectExisting) + { + FakeLuaGlobals.Current.RecordBindingsRegistration(collisionPolicy); + return default; + } + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + """; + + // The replacement adapter: the members of the generated one, each forwarding its one SDK call to FakeLuaGlobals and + // copying the result exactly as the generated adapter copies the SDK's. It holds no decision of its own. + private const string FakeAdapterSource = + """ + #nullable enable + using CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + using CheatEngine.SDK.Lua.Registration; + using CheatEngine.SDK.Lua.Runtime; + using CheatEngine.SDK.Lua.State; + namespace CheatEngine.Client.Lua.Generated; + + internal static class CheatEngineLuaRegistrationAdapter + { + internal static LuaAdmissionStatus TryAdmit(out LuaRuntimeOperation operation, out LuaState state) + { + operation = default; + state = default; + return FakeLuaGlobals.Current.Admit(); + } + + internal static void EndOperation(ref LuaRuntimeOperation operation) + { + FakeLuaGlobals.Current.EndOperation(); + } + + internal static CheatEngineLuaPublication Publish(System.Func publish, + LuaState state) + { + _ = publish(state); + FakePublication fake = FakeLuaGlobals.Current.Publish(); + return new CheatEngineLuaPublication(fake.Kind, fake.Lease, fake.FailedExport, fake.FailedStatus, + Copy(fake.Rollback)); + } + + internal static CheatEngineLuaRelease Release(object lease, LuaState state) + { + return Copy(FakeLuaGlobals.Current.Release((FakeLease) lease)); + } + + internal static CheatEngineLuaRelease ReleaseStale(object lease) + { + return Copy(FakeLuaGlobals.Current.ReleaseStale((FakeLease) lease)); + } + + private static CheatEngineLuaRelease Copy(FakeRelease release) + { + return new CheatEngineLuaRelease(release.Kind, release.RemovedCount, release.RestoredCount, + release.ReplacementCount, release.RemainingCount, release.FailedExports); + } + } + """; + + private static readonly Lazy SShared = new(Build, LazyThreadSafetyMode.ExecutionAndPublication); + + private ModuleHarness(System.Reflection.Assembly assembly) + { + Assembly = assembly; + ModuleType = assembly.GetType("TestPlugin.PluginLuaModule", true)!; + RegistrarType = assembly.GetType(RegistrarEmitter.Namespace + "." + RegistrarEmitter.RegistrarType, true)!; + } + + /// Gets the harness, compiled and loaded once per test run. + internal static ModuleHarness Shared => SShared.Value; + + internal System.Reflection.Assembly Assembly + { + get; + } + + internal Type ModuleType + { + get; + } + + /// Gets the real generated registrar of the harness assembly. + internal Type RegistrarType + { + get; + } + + /// Creates a fresh module instance through its generated public constructor. + internal ILuaModule CreateModule() + { + return (ILuaModule) Activator.CreateInstance(ModuleType)!; + } + + /// Creates the double of the harness module's SDK registration set. + internal static FakeLuaGlobals CreateGlobals() + { + return new FakeLuaGlobals(ModuleName, Exports); + } + + /// Runs the generated public Register against the double. + internal static void Register(ILuaModule module, FakeLuaGlobals globals) + { + using IDisposable active = globals.Activate(); + module.Register(); + } + + /// Runs the generated public Unregister against the double. + internal static LuaModuleReleaseOutcome Unregister(ILuaModule module, FakeLuaGlobals globals) + { + using IDisposable active = globals.Activate(); + return module.Unregister(); + } + + /// Invokes one internal static method of the real generated registrar. + internal TResult InvokeRegistrar(string method, object argument) + { + MethodInfo target = RegistrarType.GetMethod(method, BindingFlags.NonPublic | BindingFlags.Static) ?? + throw new InvalidOperationException($"The generated registrar has no '{method}' method."); + return (TResult) target.Invoke(null, [argument])!; + } + + private static ModuleHarness Build() + { + GeneratorRun run = GeneratorRun.Execute([ModuleSource], + [MetadataReference.CreateFromFile(typeof(FakeLuaGlobals).Assembly.Location)]); + Assert.Empty(run.Diagnostics); + SyntaxTree adapter = Assert.Single(run.OutputCompilation.SyntaxTrees, static tree => + tree.FilePath.EndsWith(RegistrarEmitter.AdapterHintName, StringComparison.Ordinal)); + Compilation compilation = run.OutputCompilation.ReplaceSyntaxTree(adapter, + CSharpSyntaxTree.ParseText(FakeAdapterSource, (CSharpParseOptions) adapter.Options, adapter.FilePath)); + Diagnostic[] compilerDiagnostics = + [ + .. compilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning) + ]; + Assert.True(compilerDiagnostics.Length == 0, string.Join(Environment.NewLine, compilerDiagnostics)); + + using MemoryStream image = new(); + EmitResult emit = compilation.Emit(image); + Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return new ModuleHarness(System.Reflection.Assembly.Load(image.ToArray())); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/OperationRefusalEndToEndTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/OperationRefusalEndToEndTests.cs new file mode 100644 index 0000000..3946fd2 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/EndToEnd/OperationRefusalEndToEndTests.cs @@ -0,0 +1,125 @@ +using System.Reflection; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; +using CheatEngine.SDK.Lua.Calls; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.EndToEnd; + +/// +/// CRIT-07, the result side: CheatEngine.SDK 2.0.0 refuses a Lua float at or above 2^53 where an integer or an address +/// is declared, instead of rounding it. The throwing form of the binding raises the its +/// ThrowUnexpectedResult helper raises; the Try form returns . The generated Client +/// operation classifies both as a failure and never lets the SDK +/// exception escape its TryExecute. +/// +/// +/// The bindings bodies stand for the SDK-generated ones and raise exactly what the SDK raises for such a refusal; +/// RealSdkGeneratorCompositionTests proves that the real bindings read integers with the refusing marshallers. +/// +public sealed class OperationRefusalEndToEndTests +{ + private const string RefusedValue = + "The Lua global 'getVersion' returned a number value, not an integer."; + + private const string Source = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + public static partial class Globals + { + [CheatEngineLuaOperation] + [LuaGlobal("getVersion")] + public static partial long ReadVersion(long address); + + [CheatEngineLuaOperation] + [LuaGlobal("tryGetVersion")] + public static partial bool TryReadVersion(long address, out long version); + } + """; + + // What the SDK-generated bodies do when the integer marshaller refuses 9007199254740992.0 (2^53). + private const string RefusingBindings = + """ + namespace TestPlugin; + public static partial class Globals + { + public static partial long ReadVersion(long address) => + throw new CheatEngine.SDK.Lua.Calls.LuaException( + "The Lua global 'getVersion' returned a number value, not an integer."); + + public static partial bool TryReadVersion(long address, out long version) + { + version = 0; + return false; + } + } + """; + + private static readonly Lazy SGlobals = new(Build, LazyThreadSafetyMode.ExecutionAndPublication); + + [Fact] + public void AThrowingBindingThatRefusesTheValueIsALuaErrorFailure() + { + (bool succeeded, long result, CheatEngineFailure failure) = Execute("CreateReadVersionLuaOperation"); + + Assert.False(succeeded); + Assert.Equal(0, result); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal("Lua.Operation.Globals.ReadVersion", failure.Operation); + Assert.Equal(RefusedValue, failure.Message); + LuaException refusal = Assert.IsType(failure.Exception); + // No Lua error was raised: the call returned and the SDK refused what it returned. + Assert.Equal(LuaStatus.Ok, refusal.Status); + } + + [Fact] + public void ATryBindingThatRefusesTheValueIsALuaErrorFailure() + { + (bool succeeded, long result, CheatEngineFailure failure) = Execute("CreateTryReadVersionLuaOperation"); + + Assert.False(succeeded); + Assert.Equal(0, result); + Assert.Equal(CheatEngineFailureKind.LuaError, failure.Kind); + Assert.Equal("Lua.Operation.Globals.TryReadVersion", failure.Operation); + Assert.Null(failure.Exception); + } + + private static (bool Succeeded, long Result, CheatEngineFailure Failure) Execute(string factory) + { + object operation = SGlobals.Value.GetMethod(factory, BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, [9007199254740992L])!; + ILuaOperation typed = Assert.IsType>(operation, exactMatch: false); + bool succeeded = typed.TryExecute(new ActiveContext(), out long result, out CheatEngineFailure failure); + return (succeeded, result, failure); + } + + private static Type Build() + { + GeneratorRun run = GeneratorRun.Execute(Source); + Assert.Empty(run.Diagnostics); + Compilation compilation = run.OutputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(RefusingBindings, + new CSharpParseOptions(LanguageVersion.CSharp14))); + using MemoryStream image = new(); + EmitResult emit = compilation.Emit(image); + Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return System.Reflection.Assembly.Load(image.ToArray()).GetType("TestPlugin.Globals", true)!; + } + + private sealed class ActiveContext : ILuaExecutionContext + { + public long Epoch => 1; + + public bool IsActive => true; + + public void ThrowIfExpired() + { + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs index c3c2122..b69a50e 100644 --- a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs @@ -14,10 +14,11 @@ namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; internal sealed class GeneratorRun { - private GeneratorRun(GeneratorDriver driver, GeneratorDriverRunResult result, Compilation outputCompilation, - ImmutableArray diagnostics) + private GeneratorRun(GeneratorDriver driver, GeneratorDriverRunResult result, Compilation inputCompilation, + Compilation outputCompilation, ImmutableArray diagnostics) { Driver = driver; + InputCompilation = inputCompilation; Result = result; OutputCompilation = outputCompilation; Diagnostics = diagnostics; @@ -33,6 +34,12 @@ public GeneratorDriverRunResult Result get; } + /// Gets the compilation the generator ran on, without the generated trees. + public Compilation InputCompilation + { + get; + } + public Compilation OutputCompilation { get; @@ -47,15 +54,49 @@ public ImmutableArray Diagnostics public static GeneratorRun Execute(string source) { + return Execute([source], []); + } + + /// Runs the generator over several source files, optionally with extra metadata references. + public static GeneratorRun Execute(string[] sources, MetadataReference[] additionalReferences) + { + ArgumentNullException.ThrowIfNull(sources); + ArgumentNullException.ThrowIfNull(additionalReferences); + + return ExecuteFiles( + [.. sources.Select((source, index) => (sources.Length == 1 ? string.Empty : $"Source{index}.cs", source))], + additionalReferences); + } + + /// Runs the generator over source files with explicit paths, in the given syntax-tree order. + public static GeneratorRun ExecuteFiles((string Path, string Source)[] files, + MetadataReference[]? additionalReferences = null) + { + return ExecuteFiles(files, additionalReferences, []); + } + + /// + /// Runs the Client generator next to over source files with explicit paths, so + /// the output compilation contains what every generator emitted. + /// + public static GeneratorRun ExecuteFiles((string Path, string Source)[] files, + MetadataReference[]? additionalReferences, ISourceGenerator[] otherGenerators) + { + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(otherGenerators); + CSharpParseOptions parseOptions = new(LanguageVersion.CSharp14); - SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(SourceText.From(source), parseOptions); + SyntaxTree[] syntaxTrees = + [ + .. files.Select(file => CSharpSyntaxTree.ParseText(SourceText.From(file.Source), parseOptions, file.Path)) + ]; CSharpCompilation compilation = CSharpCompilation.Create( "CheatEngineClientLuaGeneratorTests", - [syntaxTree], - GetMetadataReferences(), + syntaxTrees, + [.. GetMetadataReferences(), .. additionalReferences ?? []], new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); GeneratorDriver driver = CSharpGeneratorDriver.Create( - [new CheatEngineLuaGenerator().AsSourceGenerator()], + [new CheatEngineLuaGenerator().AsSourceGenerator(), .. otherGenerators], parseOptions: parseOptions, driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, true)); @@ -69,7 +110,7 @@ public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilati out Compilation outputCompilation, out ImmutableArray diagnostics, TestContext.Current.CancellationToken); - return new GeneratorRun(updated, updated.GetRunResult(), outputCompilation, diagnostics); + return new GeneratorRun(updated, updated.GetRunResult(), compilation, outputCompilation, diagnostics); } public string GeneratedText(string suffix) diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/IlCallScanner.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/IlCallScanner.cs new file mode 100644 index 0000000..ddd7755 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/IlCallScanner.cs @@ -0,0 +1,97 @@ +using System.Collections.Frozen; +using System.Reflection; +using System.Reflection.Emit; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +/// Lists the methods an emitted assembly's IL calls, read with System.Reflection.Metadata (no code runs). +internal static class IlCallScanner +{ + private static readonly FrozenDictionary OpCodesByValue = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => (OpCode) field.GetValue(null)!) + .ToFrozenDictionary(static opCode => opCode.Value); + + /// Returns the simple names of every method definition or member reference invoked by any method body. + public static IReadOnlyList CalledMethodNames(byte[] image) + { + using PEReader reader = new(new MemoryStream(image)); + MetadataReader metadata = reader.GetMetadataReader(); + List names = []; + foreach (MethodDefinitionHandle handle in metadata.MethodDefinitions) + { + MethodDefinition method = metadata.GetMethodDefinition(handle); + if (method.RelativeVirtualAddress == 0) + { + continue; + } + + BlobReader il = reader.GetMethodBody(method.RelativeVirtualAddress).GetILReader(); + while (il.RemainingBytes > 0) + { + byte first = il.ReadByte(); + short value = first == 0xFE ? (short) (0xFE00 | il.ReadByte()) : first; + OpCode opCode = OpCodesByValue[value]; + if (opCode.OperandType == OperandType.InlineMethod) + { + EntityHandle token = MetadataTokens.EntityHandle(il.ReadInt32()); + string? name = token.Kind switch + { + HandleKind.MethodDefinition => + metadata.GetString(metadata.GetMethodDefinition((MethodDefinitionHandle) token).Name), + HandleKind.MemberReference => + metadata.GetString(metadata.GetMemberReference((MemberReferenceHandle) token).Name), + HandleKind.MethodSpecification => DescribeSpecification(metadata, (MethodSpecificationHandle) token), + _ => null + }; + if (name is not null) + { + names.Add(name); + } + + continue; + } + + il.Offset += OperandSize(opCode.OperandType, ref il); + } + } + + return names; + } + + private static string? DescribeSpecification(MetadataReader metadata, MethodSpecificationHandle handle) + { + EntityHandle method = metadata.GetMethodSpecification(handle).Method; + return method.Kind switch + { + HandleKind.MethodDefinition => + metadata.GetString(metadata.GetMethodDefinition((MethodDefinitionHandle) method).Name), + HandleKind.MemberReference => + metadata.GetString(metadata.GetMemberReference((MemberReferenceHandle) method).Name), + _ => null + }; + } + + private static int OperandSize(OperandType operandType, ref BlobReader il) + { + switch (operandType) + { + case OperandType.InlineNone: + return 0; + case OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar: + return 1; + case OperandType.InlineVar: + return 2; + case OperandType.InlineI8 or OperandType.InlineR: + return 8; + case OperandType.InlineSwitch: + int count = il.ReadInt32(); + return count * 4; + default: + return 4; + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md index 8a78759..1f04c13 100644 --- a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md @@ -1,9 +1,22 @@ # CheatEngine.Client.SourceGenerators.Lua.Tests -This project runs `CheatEngineLuaGenerator` directly through `CSharpGeneratorDriver`. It locks down the -generated module descriptor and registration adapter, typed operation factory, invalid declaration diagnostics, -and incremental re-run behavior without requiring an attached Cheat Engine process. +This project runs `CheatEngineLuaGenerator` directly through `CSharpGeneratorDriver`, without an attached Cheat Engine +process. It validates the Client-owned source boundary: generated code keeps raw Lua values inside its implementation and +emits the contracts that Core reserves before mutation. -The SDK itself owns live Lua 5.3 lifecycle and closure tests. These tests validate only the Client-owned -source boundary: generated code must keep raw Lua values inside its implementation and emit the contracts -that Core can reserve before mutation. +| Folder | What it proves | +|---|---| +| `EndToEnd/` | C1 execution of the generated module and registrar around the CheatEngine.SDK registration leases (Q16): the test compilation replaces only the generated SDK adapter, whose members are one SDK call each, with one that forwards each call to `FakeLuaGlobals`, a managed double of the SDK admission and registration set, and calls the module's public `Register`/`Unregister`; every decision is the real registrar's. `GeneratedRegistrarMappingTests` proves that the registrar maps every SDK admission status, registration result and release kind, and fails closed. The double compares values by object identity, not by Lua type semantics. A function kept after disable (CRIT-07) runs inside the SDK registration set that the double replaces, so it has no C1 evidence here: `Composition/` and the Q16 host scenario cover it. | +| `Composition/` | CRIT-07: the real CheatEngine.SDK LuaBindings generator of the pinned package, loaded from the restored package folder the test project passes as assembly metadata, runs next to the Client generator; both outputs must compile together, the module must publish through `LuaRegistrationSet`, and integer results must use the refusing SDK marshallers. | +| `Architecture/` | C0 ratchet of the SDK-imposed registration API: the exact list of CheatEngine.SDK members the generated adapter uses, read from the emitted image, and the proof that only the adapter calls CheatEngine.SDK. | +| `Diagnostics/` | CECLUA1201-1204 shape diagnostics and the catalog check against `AnalyzerReleases.Unshipped.md` and the generator README (linked into the output). | +| `Validation/` | Incremental models (cached on unrelated edits, no Roslyn objects), identifier stability, and the descriptor contract, public projection, registration-call and no-runtime checks kept separate from the golden snapshot. | +| `Snapshots/` | The golden text of one generated module, and the constant text of the registrar and adapter. A formatting-only change updates it (and at most the literal-text checks of `ModuleContractTests`). | +| root | Operation adapters, mapper boundary diagnostics, and dependency-injection composition of generated modules. | + +The SDK owns the live Lua 5.3 lifecycle and closure tests. No Lua runtime exists in this repository, so nothing here is a +C2 fixture result, and nothing is host-qualified. Tests that evidence Q16 carry `[Trait("Qualification", "Q16")]`: + +```powershell +dotnet test --project tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj --filter-trait "Qualification=Q16" +``` diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Snapshots/ModuleSnapshots.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Snapshots/ModuleSnapshots.cs new file mode 100644 index 0000000..e0d7427 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Snapshots/ModuleSnapshots.cs @@ -0,0 +1,99 @@ +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Snapshots; + +/// +/// Golden snapshot of one generated module (DoD D.8: useful, not sufficient). A formatting-only change of the emitter +/// changes this golden and, at most, the literal-text assertions of ModuleContractTests. +/// +/// +/// The module holds no Lua logic: it hands its bindings' TryRegisterLuaFunctions to the per-assembly registrar +/// and keeps the registration lease. The registrar's decisions are proven by the EndToEnd tests, the SDK members its +/// adapter may use by GeneratedLuaSurfaceRatchetTests, and the public contract by ModuleContractTests. +/// +public sealed class ModuleSnapshots +{ + private const string ModuleSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + """; + + private const string ModuleGolden = + """ + // + #nullable enable + + namespace TestPlugin; + + internal partial class PluginLuaModule : global::CheatEngine.Client.Lua.ILuaModule + { + /// Initializes a Lua module instance for activation-scoped dependency injection. + public PluginLuaModule() + { + } + + private static readonly global::CheatEngine.Client.Lua.LuaModuleDescriptor s_descriptor = + new global::CheatEngine.Client.Lua.LuaModuleDescriptor( + "plugin", + global::System.Collections.Immutable.ImmutableArray.Create(new global::CheatEngine.Client.Lua.LuaExportDescriptor("status"), new global::CheatEngine.Client.Lua.LuaExportDescriptor("ping"))); + + // The CheatEngine.SDK registration lease this module owns while it is registered; null otherwise. Main thread only. + private object? _luaRegistration; + + /// + public global::CheatEngine.Client.Lua.LuaModuleDescriptor Descriptor => s_descriptor; + + /// + public void Register() + { + global::CheatEngine.Client.Lua.Generated.CheatEngineLuaModuleRegistrar.Register(s_descriptor, ref _luaRegistration, + static state => global::TestPlugin.PluginLuaBindings.TryRegisterLuaFunctions(state, + global::CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting)); + } + + /// + public global::CheatEngine.Client.Lua.LuaModuleReleaseOutcome Unregister() + { + return global::CheatEngine.Client.Lua.Generated.CheatEngineLuaModuleRegistrar.Unregister(s_descriptor, ref _luaRegistration); + } + } + """; + + [Fact] + public void ModuleSnapshotMatchesTheRegistrationLeaseContract() + { + GeneratorRun run = GeneratorRun.Execute(ModuleSource); + + Assert.Empty(run.Diagnostics); + Assert.Equal(Normalize(ModuleGolden) + "\n", + Normalize(run.GeneratedText("PluginLuaModule.CheatEngineLuaModule.g.cs"))); + } + + [Fact] + public void TheRegistrarAndItsSdkAdapterAreEmittedOnceWithTheirConstantText() + { + GeneratorRun run = GeneratorRun.Execute(ModuleSource); + + Assert.Equal(Normalize(RegistrarEmitter.RegistrarSource), Normalize(run.GeneratedText(RegistrarEmitter.RegistrarHintName))); + Assert.Equal(Normalize(RegistrarEmitter.AdapterSource), Normalize(run.GeneratedText(RegistrarEmitter.AdapterHintName))); + Assert.Equal(3, run.GeneratedSources.Length); + } + + private static string Normalize(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/IdentifierStabilityTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/IdentifierStabilityTests.cs new file mode 100644 index 0000000..fa56773 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/IdentifierStabilityTests.cs @@ -0,0 +1,124 @@ +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Validation; + +/// +/// Identifier stability (DoD D.2): hint names and generated text do not depend on declaration order, and unusual but +/// legal module declarations (global namespace, keyword identifiers) produce compiling code. +/// +public sealed class IdentifierStabilityTests +{ + private const string Bindings = + """ + internal static partial class AlphaLuaBindings + { + [CheatEngine.SDK.Annotations.Lua.LuaFunction("alpha_status")] + public static string Status() => "a"; + + public static CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions( + CheatEngine.SDK.Lua.State.LuaState state, + CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = + CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting) => default; + } + + internal static partial class BetaLuaBindings + { + [CheatEngine.SDK.Annotations.Lua.LuaFunction("beta_status")] + public static string Status() => "b"; + + public static CheatEngine.SDK.Lua.Registration.LuaRegistrationResult TryRegisterLuaFunctions( + CheatEngine.SDK.Lua.State.LuaState state, + CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = + CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting) => default; + } + """; + + private const string AlphaModule = + """ + [CheatEngine.Client.Lua.CheatEngineLuaModule(typeof(AlphaLuaBindings), "alpha")] + internal sealed partial class AlphaLuaModule : CheatEngine.Client.Lua.ILuaModule; + """; + + private const string BetaModule = + """ + [CheatEngine.Client.Lua.CheatEngineLuaModule(typeof(BetaLuaBindings), "beta")] + internal sealed partial class BetaLuaModule : CheatEngine.Client.Lua.ILuaModule; + """; + + [Fact] + public void HintNamesAndGeneratedTextAreStableAcrossDeclarationOrder() + { + GeneratorRun alphaFirst = GeneratorRun.Execute("namespace TestPlugin;\n" + Bindings + "\n" + AlphaModule + "\n" + BetaModule); + GeneratorRun betaFirst = GeneratorRun.Execute("namespace TestPlugin;\n" + BetaModule + "\n" + AlphaModule + "\n" + Bindings); + + Dictionary first = Sources(alphaFirst); + Dictionary second = Sources(betaFirst); + + Assert.Equal( + [ + RegistrarEmitter.RegistrarHintName, RegistrarEmitter.AdapterHintName, + "TestPlugin_AlphaLuaModule.CheatEngineLuaModule.g.cs", "TestPlugin_BetaLuaModule.CheatEngineLuaModule.g.cs" + ], + first.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(first.Keys.Order(StringComparer.Ordinal), second.Keys.Order(StringComparer.Ordinal)); + foreach ((string hintName, string text) in first) + { + Assert.Equal(text, second[hintName]); + } + } + + [Fact] + public void GlobalNamespaceModuleCompiles() + { + GeneratorRun run = GeneratorRun.Execute(Bindings + "\n" + AlphaModule); + + Assert.Empty(run.Diagnostics); + Assert.Equal("AlphaLuaModule.CheatEngineLuaModule.g.cs", Assert.Single(ModuleSources(run)).HintName); + AssertCompilesWithoutWarnings(run); + } + + [Fact] + public void KeywordNamedModuleCompiles() + { + GeneratorRun run = GeneratorRun.Execute("namespace TestPlugin;\n" + Bindings + + """ + + [CheatEngine.Client.Lua.CheatEngineLuaModule(typeof(AlphaLuaBindings))] + internal sealed partial class @event : CheatEngine.Client.Lua.ILuaModule; + """); + + Assert.Empty(run.Diagnostics); + GeneratedSourceResult source = Assert.Single(ModuleSources(run)); + Assert.EndsWith(".CheatEngineLuaModule.g.cs", source.HintName, StringComparison.Ordinal); + Assert.Contains("internal partial class @event", source.SourceText.ToString(), StringComparison.Ordinal); + Assert.Contains("public @event()", source.SourceText.ToString(), StringComparison.Ordinal); + AssertCompilesWithoutWarnings(run); + } + + private static IEnumerable ModuleSources(GeneratorRun run) + { + return run.GeneratedSources.Where(static source => + source.HintName.EndsWith(".CheatEngineLuaModule.g.cs", StringComparison.Ordinal)); + } + + private static Dictionary Sources(GeneratorRun run) + { + Assert.Empty(run.Diagnostics); + return run.GeneratedSources.ToDictionary(static source => source.HintName, + static source => source.SourceText.ToString(), StringComparer.Ordinal); + } + + private static void AssertCompilesWithoutWarnings(GeneratorRun run) + { + Diagnostic[] diagnostics = + [ + .. run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning) + ]; + Assert.True(diagnostics.Length == 0, string.Join(Environment.NewLine, diagnostics)); + using MemoryStream image = new(); + Assert.True(run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken).Success); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/IncrementalityTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/IncrementalityTests.cs new file mode 100644 index 0000000..cb83f79 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/IncrementalityTests.cs @@ -0,0 +1,164 @@ +using System.Collections.Immutable; +using System.Reflection; + +using CheatEngine.Client.SourceGenerators.Lua.Model; +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Validation; + +/// +/// Incremental-pipeline hygiene (audit ch.19 §Validation, DoD D.3): the parsed models are equatable values that root no +/// compilation object, so an unrelated edit or an identical re-parse leaves the module pipeline cached. +/// +public sealed class IncrementalityTests +{ + private const string ModuleTrackingName = "CheatEngineLuaModule"; + private const string OperationTrackingName = "CheatEngineLuaOperation"; + + private const string ModuleAndOperationSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + + internal static partial class Globals + { + [CheatEngineLuaOperation] + [LuaGlobal("getVersion")] + public static partial int ReadVersion(int address); + } + """; + + private static readonly Type[] ForbiddenModelFieldTypes = + [ + typeof(ISymbol), typeof(SyntaxNode), typeof(SyntaxTree), typeof(Compilation), typeof(SemanticModel), + typeof(Location), typeof(Diagnostic), typeof(AttributeData) + ]; + + [Fact] + public void UnrelatedEditLeavesTheModulePipelineCachedOrUnchanged() + { + GeneratorRun first = GeneratorRun.Execute(ModuleAndOperationSource); + Assert.Empty(first.Diagnostics); + Compilation edited = first.InputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText( + "namespace TestPlugin; internal static class Unrelated { internal const int Value = 42; }", + new CSharpParseOptions(LanguageVersion.CSharp14), + cancellationToken: TestContext.Current.CancellationToken)); + + GeneratorRun second = GeneratorRun.Execute(first.Driver, edited); + + GeneratorRunResult result = Assert.Single(second.Result.Results); + AssertEveryOutputIsReused(result.TrackedSteps[ModuleTrackingName], ModuleTrackingName); + AssertEveryOutputIsReused(result.TrackedSteps[OperationTrackingName], OperationTrackingName); + Assert.NotEmpty(result.TrackedOutputSteps); + foreach ((string name, ImmutableArray steps) in result.TrackedOutputSteps) + { + AssertEveryOutputIsReused(steps, name); + } + + Assert.Equal(first.GeneratedSources.Select(static source => source.SourceText.ToString()), + second.GeneratedSources.Select(static source => source.SourceText.ToString())); + } + + [Fact] + public void ReparsedIdenticalModuleProducesAnUnchangedModel() + { + GeneratorRun first = GeneratorRun.Execute(ModuleAndOperationSource); + SyntaxTree original = Assert.Single(first.InputCompilation.SyntaxTrees); + Compilation reparsed = first.InputCompilation.ReplaceSyntaxTree(original, CSharpSyntaxTree.ParseText(original.ToString(), + (CSharpParseOptions) original.Options, original.FilePath, + cancellationToken: TestContext.Current.CancellationToken)); + + GeneratorRun second = GeneratorRun.Execute(first.Driver, reparsed); + + GeneratorRunResult result = Assert.Single(second.Result.Results); + ImmutableArray moduleSteps = result.TrackedSteps[ModuleTrackingName]; + Assert.Contains(moduleSteps.SelectMany(static step => step.Outputs), + static output => output.Reason == IncrementalStepRunReason.Unchanged); + AssertEveryOutputIsReused(moduleSteps, ModuleTrackingName); + foreach ((string name, ImmutableArray steps) in result.TrackedOutputSteps) + { + AssertEveryOutputIsReused(steps, name); + } + } + + [Fact] + public void ModuleAndOperationModelsHoldNoRoslynObjects() + { + Type[] models = + [ + typeof(ModuleModel), typeof(OperationModel), typeof(OperationParameter), typeof(DiagnosticInfo), + typeof(LocationInfo), typeof(EquatableArray), typeof(EquatableArray), + typeof(EquatableArray) + ]; + + List violations = []; + HashSet visited = []; + foreach (Type model in models) + { + Assert.Contains(typeof(IEquatable<>).MakeGenericType(model), model.GetInterfaces()); + CollectForbiddenFields(model, model.Name, visited, violations); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + private static void AssertEveryOutputIsReused(ImmutableArray steps, string name) + { + Assert.NotEmpty(steps); + foreach (IncrementalGeneratorRunStep step in steps) + { + foreach ((object _, IncrementalStepRunReason reason) in step.Outputs) + { + Assert.True(reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Step '{name}' produced a '{reason}' output for an input that did not change."); + } + } + } + + private static void CollectForbiddenFields(Type type, string path, HashSet visited, List violations) + { + if (type.IsPrimitive || type == typeof(string) || type.IsEnum || !visited.Add(type)) + { + return; + } + + if (type.IsArray) + { + CollectForbiddenFields(type.GetElementType()!, path + "[]", visited, violations); + return; + } + + foreach (Type forbidden in ForbiddenModelFieldTypes) + { + if (forbidden.IsAssignableFrom(type)) + { + violations.Add($"{path} holds a {forbidden.Name}."); + } + } + + if (type.Namespace?.StartsWith("System", StringComparison.Ordinal) == true) + { + return; + } + + foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + CollectForbiddenFields(field.FieldType, path + "." + field.Name, visited, violations); + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/ModuleContractTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/ModuleContractTests.cs new file mode 100644 index 0000000..d2e2af6 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Validation/ModuleContractTests.cs @@ -0,0 +1,208 @@ +using System.Reflection; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Validation; + +/// +/// The module contract, its public projection and the emitted code are compared separately (audit ch.19 §Validation, +/// DoD D.8): a formatting change is not a contract change, and a textually stable file can still change the contract. +/// +public sealed class ModuleContractTests +{ + private const string BindingsPrefix = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.Registration; + using CheatEngine.SDK.Lua.State; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + + [LuaFunction("marker")] + public static string Marker() => "m"; + + public static LuaRegistrationResult TryRegisterLuaFunctions(LuaState state, + LuaRegistrationCollisionPolicy collisionPolicy = LuaRegistrationCollisionPolicy.RejectExisting) => default; + """; + + private const string ModuleDeclaration = + """ + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + public sealed partial class PluginLuaModule : ILuaModule; + """; + + private const string ModuleWithLegacyHelpers = + BindingsPrefix + "\n\tpublic static LuaStatus RegisterLuaFunctions(LuaState state) => default;" + + "\n\tpublic static LuaStatus UnregisterLuaFunctions(LuaState state) => default;\n}" + ModuleDeclaration; + + private const string ModuleWithoutLegacyHelpers = BindingsPrefix + "\n}" + ModuleDeclaration; + + [Fact] + public void ModuleDescriptorContractIsUnchanged() + { + ILuaModule module = (ILuaModule) Activator.CreateInstance(LoadModuleType(ModuleWithLegacyHelpers))!; + + Assert.Equal("plugin", module.Descriptor.Name); + Assert.Equal(["status", "ping", "marker"], module.Descriptor.Exports.Select(static export => export.Name)); + } + + [Fact] + public void GeneratedModulePublicProjectionIsStable() + { + Type moduleType = LoadModuleType(ModuleWithLegacyHelpers); + const BindingFlags Public = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly; + + Assert.Equal( + [".ctor()", "Descriptor", "Register()", "Unregister()", "get_Descriptor()"], + moduleType.GetMembers(Public).Select(Describe).Order(StringComparer.Ordinal)); + Assert.Equal([typeof(ILuaModule)], moduleType.GetInterfaces()); + Assert.Equal(typeof(LuaModuleReleaseOutcome), moduleType.GetMethod("Unregister")!.ReturnType); + foreach (MemberInfo member in moduleType.GetMembers(Public)) + { + Assert.DoesNotContain(PublicSignatureTypes(member), static type => + type.Namespace?.StartsWith("CheatEngine.SDK", StringComparison.Ordinal) == true); + } + } + + [Fact] + public void GeneratedModuleRegistersOnlyThroughTheOwnershipAwareSdkRegistration() + { + GeneratorRun run = GeneratorRun.Execute(ModuleWithLegacyHelpers); + string generated = run.GeneratedText("PluginLuaModule.CheatEngineLuaModule.g.cs"); + Assert.DoesNotContain("UnregisterLuaFunctions", generated, StringComparison.Ordinal); + Assert.Equal(1, Count(generated, "TryRegisterLuaFunctions(")); + Assert.Contains("LuaRegistrationCollisionPolicy.RejectExisting", generated, StringComparison.Ordinal); + + string[] invokedBindings = + [ + .. IlCallScanner.CalledMethodNames(Emit(run)) + .Where(static name => name.EndsWith("LuaFunctions", StringComparison.Ordinal)) + ]; + Assert.Equal(["TryRegisterLuaFunctions"], invokedBindings); + } + + [Fact] + public void GeneratedModuleCompilesWhenTheBindingsHaveNoLegacyHelpers() + { + GeneratorRun run = GeneratorRun.Execute(ModuleWithoutLegacyHelpers); + + Assert.Empty(run.Diagnostics); + Diagnostic[] diagnostics = + [ + .. run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning) + ]; + Assert.Empty(diagnostics); + Assert.NotEmpty(Emit(run)); + } + + [Fact] + public void GeneratedModuleCompilesInAConsumerThatDisallowsUnsafeCode() + { + GeneratorRun run = GeneratorRun.Execute(ModuleWithoutLegacyHelpers); + Compilation consumer = run.OutputCompilation.WithOptions( + ((CSharpCompilationOptions) run.OutputCompilation.Options).WithAllowUnsafe(false)); + + Assert.Empty(run.Diagnostics); + Assert.Empty(consumer.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning)); + } + + [Fact] + public void GeneratedModuleWithoutAnAttachedSdkRuntimeIsRefusedBeforeAnyLuaCall() + { + ILuaModule module = (ILuaModule) Activator.CreateInstance(LoadModuleType(ModuleWithoutLegacyHelpers))!; + + // No CheatEngine.SDK runtime is attached in this process: the real generated adapter asks for admission, the SDK + // answers Detached, and the registrar refuses before any Lua call. A module that owns nothing releases nothing. + CheatEngineActivationExpiredException refused = + Assert.Throws(module.Register); + LuaModuleReleaseOutcome outcome = module.Unregister(); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, refused.Failure.Kind); + Assert.Equal(CheatEngineHostEffect.NotStarted, refused.Failure.HostEffect); + Assert.Equal("Lua.RegisterModule", refused.Failure.Operation); + Assert.Contains("Detached", refused.Failure.Message, StringComparison.Ordinal); + Assert.Equal(LeaseReleaseKind.AlreadyReleased, outcome.Kind); + Assert.Equal("plugin", outcome.ModuleName); + } + + internal static Type LoadModuleType(string source) + { + GeneratorRun run = GeneratorRun.Execute(source); + Assert.Empty(run.Diagnostics); + return System.Reflection.Assembly.Load(Emit(run)).GetType("TestPlugin.PluginLuaModule", true)!; + } + + private static byte[] Emit(GeneratorRun run) + { + using MemoryStream image = new(); + EmitResult result = run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics)); + return image.ToArray(); + } + + private static string Describe(MemberInfo member) + { + return member switch + { + MethodBase method => method.Name + "(" + string.Join(",", + method.GetParameters().Select(static parameter => parameter.ParameterType.Name)) + ")", + _ => member.Name + }; + } + + private static IEnumerable PublicSignatureTypes(MemberInfo member) + { + switch (member) + { + case PropertyInfo property: + yield return property.PropertyType; + break; + case MethodBase method: + if (method is MethodInfo info) + { + yield return info.ReturnType; + } + + foreach (ParameterInfo parameter in method.GetParameters()) + { + yield return parameter.ParameterType; + } + + break; + case FieldInfo field: + yield return field.FieldType; + break; + } + } + + private static int Count(string value, string fragment) + { + int count = 0; + int start = 0; + while ((start = value.IndexOf(fragment, start, StringComparison.Ordinal)) >= 0) + { + count++; + start += fragment.Length; + } + + return count; + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json index 260ecf1..e4e2c7f 100644 --- a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json @@ -12,17 +12,6 @@ "Microsoft.CodeAnalysis.Common": "[5.9.0]" } }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -34,6 +23,35 @@ "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, )", @@ -44,6 +62,12 @@ "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, )", @@ -65,14 +89,6 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", - "dependencies": { - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", "resolved": "5.9.0", @@ -81,6 +97,14 @@ "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", @@ -114,11 +138,6 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -154,11 +173,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -225,21 +239,21 @@ "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -252,9 +266,9 @@ }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.CodeAnalysis.Analyzers": { "type": "CentralTransitive", diff --git a/tests/CheatEngine.Client.Tests/AllocationGateTests.cs b/tests/CheatEngine.Client.Tests/AllocationGateTests.cs index 78a57a0..5c6d9fc 100644 --- a/tests/CheatEngine.Client.Tests/AllocationGateTests.cs +++ b/tests/CheatEngine.Client.Tests/AllocationGateTests.cs @@ -1,16 +1,17 @@ +using System.Reflection; using System.Runtime.CompilerServices; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.SDK.Engine.Values; -using MemoryFluent = CheatEngine.Client.Memory.Memory; - namespace CheatEngine.Client.Tests; /// Protects measured, pure fluent paths from accidental managed allocations. public sealed class AllocationGateTests { + private static readonly IMemoryClient UnusedMemory = DispatchProxy.Create(); + [Fact] public void PureMemoryBuilderConstructionDoesNotAllocateAfterWarmup() { @@ -51,7 +52,7 @@ public void ScalarLuaResultMapperDoesNotAllocateAfterWarmup() [MethodImpl(MethodImplOptions.NoInlining)] private static MemoryAddressBuilder CreateBuilder(Address address) { - return MemoryFluent.At(address); + return UnusedMemory.At(address); } [MethodImpl(MethodImplOptions.NoInlining)] @@ -68,4 +69,13 @@ public static int Map(int source) return source; } } + + /// The memory service a measured builder is bound to; building never calls it. + public class UnusedMemoryProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + throw new NotSupportedException("Building a Fluent memory operation must not call the memory service."); + } + } } diff --git a/tests/CheatEngine.Client.Tests/Architecture/ArchitectureRatchetTests.cs b/tests/CheatEngine.Client.Tests/Architecture/ArchitectureRatchetTests.cs new file mode 100644 index 0000000..69bf485 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/ArchitectureRatchetTests.cs @@ -0,0 +1,614 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Runtime.InteropServices; + +using CheatEngine.Client.Tables; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// C0 architecture ratchet for ADR-01 ("the SDK is the only native authority") and ADR-06 ("ownership is explicit"): +/// the Client never declares native imports, never references the SDK ABI or Lua interop assemblies, reaches the SDK +/// Lua runtime only through reviewed typed SDK API, and keeps its remaining direct Lua and owner usages frozen to a +/// reviewed list that may only shrink. +/// +/// +/// +/// Everything is read from the compiled Client assemblies with System.Reflection.Metadata; no Client code runs. +/// +/// +/// and are the registered ADR-01 debt of the Client. +/// is empty and stays empty: the Client binds no Cheat Engine global itself. Each +/// entry states why it exists and how it ends: it names the CheatEngine.SDK +/// primitive that is still missing, or it is permanent, which only the unsafe Lua opt-in +/// (UnsafeLuaClient) may be. Shrinking a list is always allowed; growing it requires a registered +/// exception with its own reason and the missing SDK primitive it waits for, here, not in an external document. +/// +/// +/// is the exact inventory of the typed SDK Lua API the Client is expected to +/// use (admission, the external reset fact, SDK owners), one reason per member. It is not debt, but it is exact +/// too: an unused member leaves it, and a new one is a reviewed addition. +/// +/// +public sealed class ArchitectureRatchetTests +{ + private const string Adr01Guidance = + "ADR-01 exception: register it in the ratchet (tests/CheatEngine.Client.Tests/Architecture) with its reason and " + + "the CheatEngine.SDK primitive that is missing, or route the work through the SDK."; + + private const string TableClientType = "CheatEngine.Client.Core.Domains.TableClient"; + + private const string UnsafeLuaClientType = "CheatEngine.Client.Core.Domains.UnsafeLuaClient"; + + private const string UnsafeLuaReason = + "CheatEngine.SDK 2.0.0 exposes no protected chunk-execution service; caller-supplied Lua runs only behind " + + "EnableUnsafeLuaExecution"; + + /// + /// The Lua globals the Client may bind itself with [LuaGlobal]. Empty since the table files moved to + /// CheatEngine.SDK's CheatTableFiles: every Cheat Engine global the Client calls goes through a typed SDK + /// service. keeps it empty; a new binding is not a registrable exception. + /// + private static readonly string[] FrozenLuaGlobals = []; + + /// + /// Every direct use of the SDK Lua stack or of SDK ownership in Client code that is not sanctioned typed SDK API, by + /// outermost declaring type. Ordered ordinally; it may only shrink. + /// + private static readonly FrozenLuaUse[] FrozenLuaUsage = + [ + new(TableClientType, + "CheatEngine.SDK.Engine.Objects.CEObject::TryGetProperty``2(System.ReadOnlySpan`1,!!1&)->boolean", + "Record snapshots read Count, which ChildCount keeps for ADR-08 precision (A3): CheatEngine.SDK 2.0.0 only " + + "offers MemoryRecord.TryGetChild(int), which conflates out-of-range with failure; every other field is " + + "read through the typed MemoryRecord getters", + new LuaDebtKind.AwaitingSdkPrimitive("no MemoryRecord child-count getter in CheatEngine.SDK 2.0.0")), + new(UnsafeLuaClientType, + "CheatEngine.SDK.Lua.Calls.LuaError::FromStack(CheatEngine.SDK.Lua.State.LuaState,CheatEngine.SDK.Lua.Calls.LuaStatus)->CheatEngine.SDK.Lua.Calls.LuaError", + UnsafeLuaReason, new LuaDebtKind.Permanent()), + new(UnsafeLuaClientType, "CheatEngine.SDK.Lua.Calls.LuaError::get_Message()->string", UnsafeLuaReason, + new LuaDebtKind.Permanent()), + new(UnsafeLuaClientType, "CheatEngine.SDK.Lua.State.LuaFrame::.ctor(CheatEngine.SDK.Lua.State.LuaState)->void", + UnsafeLuaReason, new LuaDebtKind.Permanent()), + new(UnsafeLuaClientType, "CheatEngine.SDK.Lua.State.LuaFrame::Dispose()->void", UnsafeLuaReason, + new LuaDebtKind.Permanent()), + new(UnsafeLuaClientType, + "CheatEngine.SDK.Lua.State.LuaState::TryExecute(System.ReadOnlySpan`1,int32,System.ReadOnlySpan`1)->CheatEngine.SDK.Lua.Calls.LuaStatus", + UnsafeLuaReason, new LuaDebtKind.Permanent()) + ]; + + /// + /// The typed CheatEngine.SDK Lua API the Client uses, member by member, each with its reason. Exact: every member + /// is used, and every use of the scanned SDK surface is either one of these members or a + /// entry. + /// + private static readonly SanctionedSdkLuaMember[] SanctionedSdkLuaSurface = + [ + new("CheatEngine.SDK.Engine.Objects.Owned`1::ReleaseWithOutcome()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "Releases the AOB result-list owner that AobScanner.TryScanOutcome hands out, once and without throwing, " + + "and reports the outcome; OwnershipHandoff and then the match list are the only release authority (F13). " + + "The 1:1 replacement of Owned`1::Dispose (L11)"), + new("CheatEngine.SDK.Engine.Objects.Owned`1::get_Value()->!0", + "Reads the StringList of the AOB result-list owner, the only result shape AobScanner.TryScanOutcome returns"), + new("CheatEngine.SDK.Engine.Objects.StringList::TryGetCount(int32&)->boolean", + "Counts the AOB result rows through the SDK's typed StringList"), + new("CheatEngine.SDK.Engine.Objects.StringList::TryGetItem(int32,string&)->boolean", + "Reads one AOB result row through the SDK's typed StringList"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntime::TryAcquireOperationWithOutcome(CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation&)->CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus", + "The SDK's non-throwing Lua admission with its factual LuaAdmissionStatus; LuaAdmission is its only caller " + + "and classifies every refusal", "CheatEngine.Client.Core.Infrastructure.LuaAdmission"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntime::get_ExternalStateResetDetected()->boolean", + "The SDK's sticky external Lua state reset fact; SdkBoundary reads it to report a plain " + + "InvalidOperationException as RuntimeChanged", "CheatEngine.Client.Core.Infrastructure.SdkBoundary"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation::Dispose()->void", + "Ends an admission the SDK granted, on the acquiring thread, before control returns to Cheat Engine"), + new("CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation::get_State()->CheatEngine.SDK.Lua.State.LuaState", + "The Lua state of an admitted operation; every raw use of that state is a FrozenLuaUsage entry of its own") + ]; + + private static readonly Dictionary AllowedSdkAssemblyReferences = new(StringComparer.Ordinal) + { + ["CheatEngine.Client.Abstractions"] = ["CheatEngine.SDK.Engine"], + ["CheatEngine.Client.Fluent"] = ["CheatEngine.SDK.Engine"], + ["CheatEngine.Client.Extensions.DependencyInjection"] = ["CheatEngine.SDK.Engine"], + ["CheatEngine.Client.Core"] = + ["CheatEngine.SDK.Engine", "CheatEngine.SDK.Lua", "CheatEngine.SDK.Annotations", "CheatEngine.SDK.Hosting"], + ["CheatEngine.Client.Hosting"] = ["CheatEngine.SDK.Hosting"], + ["CheatEngine.Client"] = [] + }; + + private static readonly string[] ForbiddenRuntimeReferences = + [ + "CheatEngine.SDK.Abi", + "CheatEngine.SDK.Lua.Interop", + "CheatEngine.Client.SourceGenerators.Lua" + ]; + + private static readonly string[] ForbiddenInteropAttributes = + [ + "System.Runtime.InteropServices.DllImportAttribute", + "System.Runtime.InteropServices.LibraryImportAttribute", + "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute" + ]; + + [Fact] + public void ClientAssembliesDeclareNoNativeImports() + { + List violations = []; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + violations.AddRange(FindNativeImports(ClientAssemblyCatalog.Load(assembly).Location)); + } + + Assert.True(violations.Count == 0, + string.Join(Environment.NewLine, violations.Select(static violation => $"{violation} {Adr01Guidance}"))); + } + + [Fact] + public void NativeImportScanDetectsEveryForbiddenForm() + { + string[] violations = FindNativeImports(typeof(NativeImportFixture).Assembly.Location) + .Where(static violation => violation.Contains(nameof(NativeImportFixture), StringComparison.Ordinal) || + violation.Contains("NativeLibrary", StringComparison.Ordinal)) + .ToArray(); + + Assert.Contains(violations, static violation => violation.Contains("P/Invoke", StringComparison.Ordinal)); + Assert.Contains(violations, static violation => violation.Contains("UnmanagedCallersOnly", StringComparison.Ordinal)); + Assert.Contains(violations, static violation => violation.Contains("function pointer", StringComparison.Ordinal)); + Assert.Contains(violations, static violation => violation.Contains("NativeLibrary", StringComparison.Ordinal)); + } + + [Fact] + public void OnlyCoreAndHostingReferenceSdkRuntimeAssemblies() + { + List violations = []; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + HashSet allowed = new(AllowedSdkAssemblyReferences[assembly], StringComparer.Ordinal); + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach (AssemblyReferenceHandle handle in reader.AssemblyReferences) + { + string name = reader.GetString(reader.GetAssemblyReference(handle).Name); + if (ForbiddenRuntimeReferences.Contains(name, StringComparer.Ordinal)) + { + violations.Add($"{assembly} references {name} at runtime. {Adr01Guidance}"); + } + else if (MetadataSurface.IsSdkAssembly(name) && !allowed.Contains(name)) + { + violations.Add($"{assembly} references {name}; only Core (and Hosting for the plugin base) may " + + $"reference SDK runtime assemblies beyond descriptive Engine values. {Adr01Guidance}"); + } + } + }); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + /// + /// Core stays logger-free (audit ch.24, open issue O3): its diagnostic events go through an internal sink that the + /// dependency-injection package implements, so Core never references a logging assembly or package. + /// + [Fact] + public void CoreReferencesNoLoggingAssembly() + { + List references = []; + ClientAssemblyCatalog.ReadMetadata("CheatEngine.Client.Core", (reader, _) => + { + foreach (AssemblyReferenceHandle handle in reader.AssemblyReferences) + { + references.Add(reader.GetString(reader.GetAssemblyReference(handle).Name)); + } + }); + + Assert.NotEmpty(references); + Assert.DoesNotContain(references, static name => + name.StartsWith("Microsoft.Extensions.Logging", StringComparison.Ordinal) || + name.StartsWith("Microsoft.Extensions.DependencyInjection", StringComparison.Ordinal) || + name.Equals("Serilog", StringComparison.Ordinal) || name.StartsWith("NLog", StringComparison.Ordinal)); + } + + [Fact] + public void LuaGlobalBindingsAreFrozenToTheRegisteredAdr01Exceptions() + { + List violations = []; + foreach (ReflectionAssembly assembly in ClientAssemblyCatalog.LoadAll()) + { + foreach (Type type in assembly.GetTypes()) + { + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Static | BindingFlags.Instance | + BindingFlags.DeclaredOnly)) + { + foreach (CustomAttributeData attribute in method.GetCustomAttributesData()) + { + if (attribute.AttributeType.FullName == "CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute" && + attribute.ConstructorArguments[0].Value is string name && + !FrozenLuaGlobals.Contains(name, StringComparer.Ordinal)) + { + violations.Add($"{type.FullName}.{method.Name} declares [LuaGlobal(\"{name}\")]; the Client " + + "binds no Cheat Engine global itself. Call the typed CheatEngine.SDK service."); + } + } + } + } + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void FrozenLuaGlobalsStaysEmpty() + { + // The ratchet only shrinks, and it reached zero: a Cheat Engine global that CheatEngine.SDK does not wrap is an + // SDK issue, never a Client binding. + Assert.Empty(FrozenLuaGlobals); + } + + [Fact] + public void SdkLuaSurfaceUseIsSanctionedTypedApiOrRegisteredDebt() + { + List uses = [.. ClientAssemblyCatalog.Names.SelectMany(LuaUsageScanner.Scan)]; + Dictionary sanctioned = + SanctionedSdkLuaSurface.ToDictionary(static member => member.Member, StringComparer.Ordinal); + + string[] debt = [.. uses.Where(use => !sanctioned.ContainsKey(use.Symbol)).Select(static use => use.ToString())]; + string[] frozen = [.. FrozenLuaUsage.Select(static entry => entry.Usage)]; + string[] added = [.. debt.Except(frozen, StringComparer.Ordinal)]; + string[] removed = [.. frozen.Except(debt, StringComparer.Ordinal)]; + string[] unusedSanctioned = + [.. sanctioned.Keys.Where(member => !uses.Exists(use => use.Symbol == member)).Order(StringComparer.Ordinal)]; + string[] misplaced = + [ + .. uses.Where(use => sanctioned.TryGetValue(use.Symbol, out SanctionedSdkLuaMember? member) && + member.OnlyIn is { } owner && owner != use.OuterType) + .Select(static use => use.ToString()) + ]; + + Assert.True(added.Length == 0, + "New direct Lua-stack or SDK-owner usage in Client code:" + Environment.NewLine + + string.Join(Environment.NewLine, added) + Environment.NewLine + Adr01Guidance); + Assert.True(removed.Length == 0, + "These frozen ADR-01 usages no longer exist; shrink FrozenLuaUsage (ratchet):" + Environment.NewLine + + string.Join(Environment.NewLine, removed)); + Assert.True(unusedSanctioned.Length == 0, + "These sanctioned SDK Lua members are no longer used; shrink SanctionedSdkLuaSurface:" + Environment.NewLine + + string.Join(Environment.NewLine, unusedSanctioned)); + Assert.True(misplaced.Length == 0, + "These sanctioned SDK Lua members are used outside their single owner:" + Environment.NewLine + + string.Join(Environment.NewLine, misplaced)); + } + + [Fact] + public void LuaInventoriesAreOrderedAndEveryEntryStatesHowItEnds() + { + string[] usages = [.. FrozenLuaUsage.Select(static entry => entry.Usage)]; + string[] members = [.. SanctionedSdkLuaSurface.Select(static member => member.Member)]; + List violations = []; + foreach (FrozenLuaUse entry in FrozenLuaUsage) + { + if (string.IsNullOrWhiteSpace(entry.Reason)) + { + violations.Add($"{entry.Usage} has no reason."); + } + + switch (entry.Kind) + { + case LuaDebtKind.Permanent + when !entry.Usage.StartsWith(UnsafeLuaClientType + " -> ", StringComparison.Ordinal): + violations.Add($"{entry.Usage} is Permanent; only UnsafeLuaClient may be."); + break; + case LuaDebtKind.AwaitingSdkPrimitive awaiting when string.IsNullOrWhiteSpace(awaiting.Primitive): + violations.Add($"{entry.Usage} does not name the missing SDK primitive."); + break; + } + + if (members.Contains(entry.Usage.Split(" -> ", 2)[1], StringComparer.Ordinal)) + { + violations.Add($"{entry.Usage} is sanctioned SDK API; it cannot also be debt."); + } + } + + violations.AddRange(SanctionedSdkLuaSurface.Where(static member => string.IsNullOrWhiteSpace(member.Reason)) + .Select(static member => $"{member.Member} has no reason.")); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + Assert.Equal(usages.Order(StringComparer.Ordinal), usages); + Assert.Equal(usages.Length, usages.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(members.Order(StringComparer.Ordinal), members); + Assert.Equal(members.Length, members.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(UnsafeLuaReason, + Assert.Single(FrozenLuaUsage.Where(static entry => entry.Kind is LuaDebtKind.Permanent) + .Select(static entry => entry.Reason).Distinct(StringComparer.Ordinal))); + } + + [Fact] + public void ClientCodeNeverConstructsSdkOwnersOrAdoptsHandles() + { + List violations = []; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach (MemberReferenceHandle handle in reader.MemberReferences) + { + string member = MetadataSurface.DescribeMember(reader, handle, + out MetadataSurface.TypeIdentity declaringType); + if (declaringType.IsSdk && IsOwnershipAdoption(member, declaringType.FullName)) + { + violations.Add($"{assembly} references {member}: only qualified SDK factories may create " + + $"owners or adopt handles (ADR-06). {Adr01Guidance}"); + } + } + }); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void EveryPublicSdkAbandonMemberIsAForbiddenOwnershipBypass() + { + string[] abandonMembers = + [ + .. ConsumedSdkAssemblies.All.SelectMany(static assembly => assembly.GetExportedTypes()) + .SelectMany(static type => type.GetMethods(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(static method => method.Name.StartsWith("Abandon", StringComparison.Ordinal)) + .Select(method => $"{type.FullName}::{method.Name}()")) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + ]; + + // ADR-06: abandoning an owner leaks its Cheat Engine object on purpose. The Client never does it, including the + // session-level MemoryScanSession.Abandon of CheatEngine.SDK 2.0.0. + Assert.Contains("CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::Abandon()", abandonMembers); + Assert.Contains("CheatEngine.SDK.Engine.Objects.Owned`1::Abandon()", abandonMembers); + Assert.All(abandonMembers, static member => + Assert.True(IsOwnershipAdoption(member, member.Split("::", 2)[0]), $"{member} is not forbidden.")); + } + + [Fact] + public void ClientNeverReinvokesPluginEnable() + { + List violations = []; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach (MemberReferenceHandle handle in reader.MemberReferences) + { + string member = MetadataSurface.DescribeMember(reader, handle, + out MetadataSurface.TypeIdentity declaringType); + bool pluginHostMember = declaringType.FullName == "CheatEngine.SDK.Hosting.Bootstrap.PluginHost" && + !member.Contains("::get_Context()", StringComparison.Ordinal); + bool pluginLifecycleCall = declaringType.FullName == "CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin" && + (member.Contains("::OnEnable(", StringComparison.Ordinal) || + member.Contains("::OnDisable(", StringComparison.Ordinal)); + if (pluginHostMember || pluginLifecycleCall) + { + violations.Add($"{assembly} references {member}; the Client never re-invokes or drives the SDK " + + "plugin lifecycle."); + } + } + }); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void ClientAssembliesDeclareNoFinalizers() + { + List violations = []; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach (MethodDefinitionHandle handle in reader.MethodDefinitions) + { + MethodDefinition method = reader.GetMethodDefinition(handle); + if (reader.GetString(method.Name) == "Finalize" && method.GetParameters().Count == 0 && + (method.Attributes & MethodAttributes.Virtual) != 0) + { + violations.Add($"{MetadataSurface.ResolveTypeDefinition(reader, method.GetDeclaringType()).FullName} " + + "declares a finalizer; a finalizer must never repair a forgotten cleanup by touching " + + "Lua or the GUI."); + } + } + }); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void DomainTypesDoNotHoldAServiceProvider() + { + const BindingFlags AllMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | + BindingFlags.Instance | BindingFlags.DeclaredOnly; + List violations = []; + foreach (Type type in ClientAssemblyCatalog.Load("CheatEngine.Client.Core").GetTypes()) + { + foreach (FieldInfo field in type.GetFields(AllMembers).Where(static field => + typeof(IServiceProvider).IsAssignableFrom(field.FieldType))) + { + violations.Add($"{type.FullName} field {field.Name}"); + } + + foreach (PropertyInfo property in type.GetProperties(AllMembers).Where(static property => + typeof(IServiceProvider).IsAssignableFrom(property.PropertyType))) + { + violations.Add($"{type.FullName} property {property.Name}"); + } + + foreach (ConstructorInfo constructor in type.GetConstructors(AllMembers)) + { + violations.AddRange(constructor.GetParameters() + .Where(static parameter => typeof(IServiceProvider).IsAssignableFrom(parameter.ParameterType)) + .Select(parameter => $"{type.FullName} constructor parameter {parameter.Name}")); + } + } + + Assert.True(violations.Count == 0, + "Core domain types must receive their dependencies explicitly; no service locator inside a domain:" + + Environment.NewLine + string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void TableClientExposesNoStreamOrByteLoadPath() + { + Type[] forbidden = + [ + typeof(Stream), typeof(byte[]), typeof(ReadOnlySpan), typeof(Span), typeof(ReadOnlyMemory), + typeof(Memory), typeof(IEnumerable) + ]; + MethodInfo[] methods = typeof(ITableClient).GetMethods(); + List violations = []; + foreach (MethodInfo method in methods) + { + foreach (ParameterInfo parameter in method.GetParameters()) + { + Type type = parameter.ParameterType.IsByRef ? parameter.ParameterType.GetElementType()! : parameter.ParameterType; + if (forbidden.Any(candidate => candidate.IsAssignableFrom(type))) + { + violations.Add($"{method.Name} parameter {parameter.Name} accepts a raw table payload ({type})."); + } + } + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + Assert.All(methods.Where(static method => method.Name.Contains("LoadTrustedTable", StringComparison.Ordinal)), + static method => Assert.Equal(typeof(TableLoadRequest), method.GetParameters()[0].ParameterType)); + Assert.All(methods.Where(static method => method.Name.Contains("SaveTable", StringComparison.Ordinal)), + static method => Assert.Equal(typeof(TableSaveRequest), method.GetParameters()[0].ParameterType)); + } + + private static bool IsOwnershipAdoption(string member, string declaringType) + { + return (declaringType == "CheatEngine.SDK.Engine.Objects.CEObject" && + member.Contains("::.ctor(", StringComparison.Ordinal)) || + (member.Contains("::.ctor(", StringComparison.Ordinal) && + member.Contains("CheatEngine.SDK.Engine.Objects.CEObject", StringComparison.Ordinal)) || + member.Contains("::FromHandle(", StringComparison.Ordinal) || + declaringType == "CheatEngine.SDK.Engine.Objects.ICEObject`1" || + (declaringType == "CheatEngine.SDK.Engine.Objects.Owned`1" && + member.Contains("::Transfer(", StringComparison.Ordinal)) || + member.Contains("::Abandon", StringComparison.Ordinal) || + member.Contains("::PushUncheckedFunction(", StringComparison.Ordinal); + } + + private static List FindNativeImports(string assemblyPath) + { + List violations = []; + using FileStream stream = File.OpenRead(assemblyPath); + using System.Reflection.PortableExecutable.PEReader peReader = new(stream); + MetadataReader reader = peReader.GetMetadataReader(); + string assembly = reader.GetString(reader.GetAssemblyDefinition().Name); + + for (int row = 1; row <= reader.GetTableRowCount(TableIndex.ModuleRef); row++) + { + ModuleReference module = reader.GetModuleReference(MetadataTokens.ModuleReferenceHandle(row)); + violations.Add($"{assembly} imports native module '{reader.GetString(module.Name)}'."); + } + + foreach (MethodDefinitionHandle handle in reader.MethodDefinitions) + { + MethodDefinition method = reader.GetMethodDefinition(handle); + string owner = $"{MetadataSurface.ResolveTypeDefinition(reader, method.GetDeclaringType()).FullName}." + + reader.GetString(method.Name); + if ((method.Attributes & MethodAttributes.PinvokeImpl) != 0) + { + violations.Add($"{assembly}: {owner} is a P/Invoke declaration."); + } + + foreach (CustomAttributeHandle attributeHandle in method.GetCustomAttributes()) + { + string attributeType = MetadataSurface.GetAttributeTypeName(reader, reader.GetCustomAttribute(attributeHandle)); + if (ForbiddenInteropAttributes.Contains(attributeType, StringComparer.Ordinal)) + { + violations.Add($"{assembly}: {owner} carries {attributeType.Split('.')[^1]}."); + } + } + + if (DescribeSignature(method).Contains("fnptr(", StringComparison.Ordinal)) + { + violations.Add($"{assembly}: {owner} exposes a function pointer in its signature."); + } + } + + foreach (MemberReferenceHandle handle in reader.MemberReferences) + { + string member = MetadataSurface.DescribeMember(reader, handle, out MetadataSurface.TypeIdentity type); + if (type.FullName == "System.Runtime.InteropServices.NativeLibrary") + { + violations.Add($"{assembly} references {member}."); + } + } + + return violations; + } + + private static string DescribeSignature(MethodDefinition method) + { + MethodSignature signature = + method.DecodeSignature(new MetadataSurface.SignatureNameProvider(), null); + return string.Join(",", signature.ParameterTypes.Select(static parameter => parameter.Display)) + "->" + + signature.ReturnType.Display; + } + + /// One registered ADR-01 debt entry: a direct SDK Lua-stack or ownership use, why, and how it ends. + private sealed record FrozenLuaUse(string Usage, string Reason, LuaDebtKind Kind) + { + internal FrozenLuaUse(string outerType, string symbol, string reason, LuaDebtKind kind) + : this($"{outerType} -> {symbol}", reason, kind) + { + } + } + + /// A typed SDK Lua member the Client uses, why, and the single Client type allowed to use it, if any. + private sealed record SanctionedSdkLuaMember(string Member, string Reason, string? OnlyIn = null); + + /// + /// How a registered ADR-01 debt entry ends: when the consumed SDK offers the missing primitive, or never, + /// for the unsafe Lua opt-in only. There is no transitional kind: a need the SDK already covers is routed + /// through it. + /// + private abstract record LuaDebtKind + { + /// Kept by design: the SDK offers no replacement and the Client deliberately exposes the capability. + internal sealed record Permanent : LuaDebtKind; + + /// Kept until the consumed SDK offers the named primitive. + internal sealed record AwaitingSdkPrimitive(string Primitive) : LuaDebtKind; + } + + /// Deliberate native-interop forms proving that the scan is not vacuous. Never called. + private static unsafe class NativeImportFixture + { + // The ratchet must see a real DllImport; LibraryImport would hide it behind generated code. +#pragma warning disable SYSLIB1054 + [DllImport("kernel32.dll")] + internal static extern uint GetCurrentThreadId(); +#pragma warning restore SYSLIB1054 + + [UnmanagedCallersOnly] + internal static int Callback(int value) + { + return value; + } + + internal static delegate* unmanaged GetCallback() + { + return &Callback; + } + + internal static bool Load() + { + return NativeLibrary.TryLoad("kernel32.dll", out _); + } + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/CapabilityRatchetTests.cs b/tests/CheatEngine.Client.Tests/Architecture/CapabilityRatchetTests.cs new file mode 100644 index 0000000..ff5bcb2 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/CapabilityRatchetTests.cs @@ -0,0 +1,286 @@ +using System.Reflection; +using System.Reflection.Metadata; + +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// C0 capability ratchets (audit ADR-09, ADR-09a, A17-19, A17-20, SRC02-08): runtime and target observations call +/// only read-only CheatEngine.SDK operations, the one selection call is reachable only from +/// ProcessClient.TryAttach, and every capability has one operational adapter on the CheatEngine.SDK major the +/// Client supports (_CheatEngineClientSupportedSdkMajor in eng/CheatEngineSdk.props). +/// +/// Everything is read from the compiled Client assemblies; no Client code runs. +public sealed class CapabilityRatchetTests +{ + private const string CoreAssembly = "CheatEngine.Client.Core"; + + private const string ObservationPortType = "CheatEngine.Client.Core.Domains.SdkRuntimeObservationPort"; + + private const string SelectionPortType = "CheatEngine.Client.Core.Domains.SdkProcessSelectionPort"; + + private const string SelectionPortInterface = "CheatEngine.Client.Core.Domains.IProcessSelectionPort"; + + private const string ProcessClientType = "CheatEngine.Client.Core.Domains.ProcessClient"; + + private const string SelectAndObserve = "RuntimeProcessOperations::SelectAndObserve"; + + /// The CheatEngine.SDK types whose operations load or save a table or mutate its records, a host effect. + private static readonly string[] TableEffectTypes = + [ + "CheatEngine.SDK.Engine.AddressList.AddressListMutations", + "CheatEngine.SDK.Engine.Tables.CheatTableFiles" + ]; + + /// The CheatEngine.SDK types whose operations observe or select Cheat Engine's host and target. + private static readonly string[] RuntimeOperationTypes = + [ + "CheatEngine.SDK.Engine.Processes.RuntimeHostOperations", + "CheatEngine.SDK.Engine.Processes.RuntimeObservations", + "CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations", + "CheatEngine.SDK.Engine.Targets.TargetSelection" + ]; + + /// + /// The exact read-only CheatEngine.SDK operations the observation port calls: observations, never effects. Each one + /// is used, so the allowlist cannot pass vacuously. + /// + private static readonly string[] ReadOnlySdkOperations = + [ + "RuntimeHostOperations::ObserveHost", + "RuntimeHostOperations::TryGetCheatEngineFileVersion", + "RuntimeHostOperations::TryGetOperatingSystem", + "RuntimeHostOperations::TryGetSystemArchitecture", + "RuntimeHostOperations::TryIsCheatEngine64Bit", + "RuntimeObservations::TryObserveRuntimeInfo", + "RuntimeProcessOperations::ObserveCurrent", + "RuntimeProcessOperations::ObserveTargetArchitecture", + "RuntimeProcessOperations::TryGetConfiguredPointerSize", + "TargetSelection::ObserveCurrent", + "TargetSelection::ValidateCurrent" + ]; + + /// The CheatEngine.SDK types that register and unregister symbols, a host effect. + private static readonly string[] SymbolRegistrationTypes = + [ + "CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease", + "CheatEngine.SDK.Engine.Inspection.SymbolRegistry" + ]; + + /// Types that observe the runtime and must never reach a binding or an SDK operation with a host effect. + private static readonly string[] ObservationOnlyTypes = + [ + "CheatEngine.Client.Core.Domains.RuntimeClient", + "CheatEngine.Client.Core.Domains.RuntimeObservationMapping", + "CheatEngine.Client.Core.Domains.RuntimeObserver", + ObservationPortType, + "CheatEngine.Client.Core.Domains.TargetArchitectureObserver", + "CheatEngine.Client.Core.Infrastructure.ConsumedSdkIdentity" + ]; + + /// + /// The public client interface of every ClientCapabilityId and the one Core adapter that serves it (plan + /// L18: no capability is contract-only). Names, not types, so the experimental interfaces need no suppression. + /// + private static readonly (string Capability, string Contract, string Adapter)[] CapabilityAdapters = + [ + ("ProcessSelection", "CheatEngine.Client.Processes.IProcessClient", + "CheatEngine.Client.Core.Domains.ProcessClient"), + ("TypedMemory", "CheatEngine.Client.Memory.IMemoryClient", "CheatEngine.Client.Core.Domains.MemoryClient"), + ("PatternScanning", "CheatEngine.Client.Scanning.IPatternScanner", + "CheatEngine.Client.Core.Domains.PatternScanner"), + ("ValueScanning", "CheatEngine.Client.Scanning.IValueScanner", + "CheatEngine.Client.Core.Domains.ValueScanning.ValueScanner"), + ("Inspection", "CheatEngine.Client.Inspection.IInspectionClient", + "CheatEngine.Client.Core.Domains.InspectionClient"), + ("Tables", "CheatEngine.Client.Tables.ITableClient", "CheatEngine.Client.Core.Domains.TableClient"), + ("ProtectedLua", "CheatEngine.Client.Lua.ILuaClient", "CheatEngine.Client.Core.Domains.LuaClient"), + ("UnsafeLuaExecution", "CheatEngine.Client.Lua.IUnsafeLuaClient", + "CheatEngine.Client.Core.Domains.UnsafeLuaClient"), + ("Allocations", "CheatEngine.Client.Allocations.IAllocationClient", + "CheatEngine.Client.Core.Domains.Allocations.AllocationClient"), + ("Assembly", "CheatEngine.Client.Assembly.IAssemblyClient", + "CheatEngine.Client.Core.Domains.Assembly.AssemblyClient"), + ("AutoAssemblerPatches", "CheatEngine.Client.Assembly.IAutoAssemblerClient", + "CheatEngine.Client.Core.Domains.Assembly.AutoAssemblerClient") + ]; + + [Fact] + [Trait("Qualification", "Q45")] + public void RuntimeProbeCallsOnlyReadOnlySdkOperations() + { + List sdkCalls = ReadSdkCalls(); + string[] runtimeCalls = + [ + .. sdkCalls.Where(static call => RuntimeOperationTypes.Contains(call.DeclaringType, StringComparer.Ordinal)) + .Select(static call => call.ToString()) + ]; + string[] portOperations = + [ + .. sdkCalls.Where(static call => call.OuterType == ObservationPortType && + RuntimeOperationTypes.Contains(call.DeclaringType, StringComparer.Ordinal)) + .Select(static call => call.Operation) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + ]; + string[] outsideThePorts = + [ + .. sdkCalls.Where(static call => RuntimeOperationTypes.Contains(call.DeclaringType, StringComparer.Ordinal) && + call.OuterType != ObservationPortType && + !(call.OuterType == SelectionPortType && call.Operation == SelectAndObserve)) + .Select(static call => call.ToString()) + ]; + string[] selectionPortOperations = + [ + .. sdkCalls.Where(static call => call.OuterType == SelectionPortType) + .Select(static call => call.Operation) + .Distinct(StringComparer.Ordinal) + ]; + string[] tableCalls = + [ + .. sdkCalls.Where(static call => ObservationOnlyTypes.Contains(call.OuterType, StringComparer.Ordinal) && + TableEffectTypes.Contains(call.DeclaringType, StringComparer.Ordinal)) + .Select(static call => call.ToString()) + ]; + string[] symbolCalls = + [ + .. sdkCalls.Where(static call => ObservationOnlyTypes.Contains(call.OuterType, StringComparer.Ordinal) && + SymbolRegistrationTypes.Contains(call.DeclaringType, StringComparer.Ordinal)) + .Select(static call => call.ToString()) + ]; + + // The allowlist is exact and non-empty: every read-only operation is used, and nothing else is. + Assert.NotEmpty(ReadOnlySdkOperations); + Assert.True(ReadOnlySdkOperations.SequenceEqual(portOperations, StringComparer.Ordinal), + "The observation port calls SDK operations outside the read-only allowlist, or no longer calls one of " + + "them (Q45):" + Environment.NewLine + string.Join(Environment.NewLine, portOperations)); + Assert.True(outsideThePorts.Length == 0, + "Only the observation port, and the selection port for SelectAndObserve, may call a CheatEngine.SDK runtime " + + "or process operation:" + Environment.NewLine + string.Join(Environment.NewLine, outsideThePorts)); + Assert.Equal([SelectAndObserve], selectionPortOperations); + Assert.DoesNotContain(runtimeCalls, static call => call.Contains("::SelectAndObserve(", StringComparison.Ordinal) && + !call.StartsWith(SelectionPortType + ".", StringComparison.Ordinal)); + Assert.True(tableCalls.Length == 0, + "An observation-only type references CheatTableFiles or AddressListMutations (Q45):" + Environment.NewLine + + string.Join(Environment.NewLine, tableCalls)); + Assert.True(symbolCalls.Length == 0, + "An observation-only type references the CheatEngine.SDK symbol registry (Q45):" + Environment.NewLine + + string.Join(Environment.NewLine, symbolCalls)); + } + + [Fact] + [Trait("Qualification", "Q45")] + public void SelectAndObserveIsReachableOnlyFromTryAttach() + { + // The one Cheat Engine call of the Processes domain that changes the selected target: every call to the + // selection port, through its interface or its production type, is in ProcessClient.TryAttach (its lambda or + // local function), so no observation can select a process. + List<(string Type, string Method)> callers = []; + ClientAssemblyCatalog.ReadMetadata(CoreAssembly, (reader, peReader) => + { + foreach (MetadataSurface.IlReference reference in MetadataSurface.ReadIlReferences(reader, peReader)) + { + if (reference.Token.Kind != HandleKind.MethodDefinition) + { + continue; + } + + MethodDefinition target = reader.GetMethodDefinition((MethodDefinitionHandle) reference.Token); + string declaringType = MetadataSurface.ResolveTypeDefinition(reader, target.GetDeclaringType()).FullName; + if (reader.GetString(target.Name) == "SelectAndObserve" && + declaringType is SelectionPortInterface or SelectionPortType) + { + callers.Add((reference.OuterType, reference.Method)); + } + } + }); + + Assert.NotEmpty(callers); + Assert.All(callers, static caller => + { + Assert.Equal(ProcessClientType, caller.Type); + Assert.True(caller.Method == "TryAttach" || caller.Method.StartsWith("", StringComparison.Ordinal), + $"{caller.Type}.{caller.Method} calls SelectAndObserve outside TryAttach."); + }); + } + + [Fact] + [Trait("Qualification", "Q44")] + public void EveryCapabilityHasAnOperationalAdapter() + { + // SRC02-08, plan L18: on the supported CheatEngine.SDK major every capability's client interface has exactly + // one implementation, its operational Core adapter, and no Client assembly keeps an Unavailable* placeholder + // that refuses every operation. Making a domain contract-only again must update this test deliberately, + // together with the catalog's implementation gate and the capability tables. + Type[] types = [.. ClientAssemblyCatalog.LoadAll().SelectMany(static assembly => assembly.GetTypes())]; + Type[] concrete = [.. types.Where(static type => type is { IsInterface: false, IsAbstract: false })]; + string[] capabilities = + [ + .. typeof(ClientCapabilityId).GetProperties(BindingFlags.Public | BindingFlags.Static) + .Where(static property => property.PropertyType == typeof(ClientCapabilityId)) + .Select(static property => property.Name) + .Order(StringComparer.Ordinal) + ]; + int referencedSdkMajor = ClientAssemblyCatalog.Load(CoreAssembly).GetReferencedAssemblies() + .Single(static name => name.Name == "CheatEngine.SDK.Engine").Version!.Major; + + Assert.Equal(SdkPin.SupportedMajor, referencedSdkMajor); + Assert.Equal(capabilities, + CapabilityAdapters.Select(static adapter => adapter.Capability).Order(StringComparer.Ordinal)); + foreach ((string capability, string contract, string adapter) in CapabilityAdapters) + { + Type contractType = Assert.Single(types, type => type.FullName == contract); + string[] implementations = + [ + .. concrete.Where(type => contractType.IsAssignableFrom(type)) + .Select(static type => type.FullName!) + .Order(StringComparer.Ordinal) + ]; + Assert.True(implementations.SequenceEqual([adapter], StringComparer.Ordinal), + $"{capability}: {contract} must be implemented only by its operational adapter {adapter}, not by " + + $"[{string.Join(", ", implementations)}]."); + } + + Assert.DoesNotContain(concrete, static type => type.Name.StartsWith("Unavailable", StringComparison.Ordinal)); + } + + /// Returns every reference from a Core method body to a CheatEngine.SDK member. + private static List ReadSdkCalls() + { + List calls = []; + ClientAssemblyCatalog.ReadMetadata(CoreAssembly, (reader, peReader) => + { + foreach (MetadataSurface.IlReference reference in MetadataSurface.ReadIlReferences(reader, peReader)) + { + if (MetadataSurface.AsMemberReference(reader, reference.Token) is not { } handle) + { + continue; + } + + string member = MetadataSurface.DescribeMember(reader, handle, + out MetadataSurface.TypeIdentity declaringType); + if (declaringType.IsSdk) + { + string name = reader.GetString(reader.GetMemberReference(handle).Name); + calls.Add(new SdkCall(reference.OuterType, reference.Method, declaringType.FullName, name, member)); + } + } + }); + + return calls; + } + + /// One reference from a Core method body (by outermost type) to a CheatEngine.SDK member. + private sealed record SdkCall(string OuterType, string Method, string DeclaringType, string Name, string Member) + { + /// Gets the operation as ShortType::Name, for example RuntimeHostOperations::ObserveHost. + internal string Operation => $"{DeclaringType[(DeclaringType.LastIndexOf('.') + 1)..]}::{Name}"; + + public override string ToString() + { + return $"{OuterType}.{Method} -> {Member}"; + } + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/ClientAssemblyCatalog.cs b/tests/CheatEngine.Client.Tests/Architecture/ClientAssemblyCatalog.cs new file mode 100644 index 0000000..f68949b --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/ClientAssemblyCatalog.cs @@ -0,0 +1,52 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// Locates the shipped Client runtime assemblies that the architecture ratchet inspects. +/// +/// The list is explicit so that a new shipped assembly is a reviewed change. Every assembly is loaded from the test +/// output (the same binaries the packages contain) and read as metadata; no Client code runs. +/// +internal static class ClientAssemblyCatalog +{ + /// The Client runtime libraries in dependency order, followed by the umbrella facade. + internal static readonly string[] Names = + [ + "CheatEngine.Client.Abstractions", + "CheatEngine.Client.Core", + "CheatEngine.Client.Fluent", + "CheatEngine.Client.Extensions.DependencyInjection", + "CheatEngine.Client.Hosting", + "CheatEngine.Client" + ]; + + /// Loads every Client runtime assembly by name. + internal static IEnumerable LoadAll() + { + foreach (string name in Names) + { + yield return Load(name); + } + } + + /// Loads one Client runtime assembly by simple name. + internal static ReflectionAssembly Load(string name) + { + return ReflectionAssembly.Load(name); + } + + /// Opens the metadata of one Client runtime assembly without executing it. + /// The simple assembly name. + /// Reads the metadata; the reader is valid only during the call. + internal static void ReadMetadata(string name, Action action) + { + ArgumentNullException.ThrowIfNull(action); + string path = Load(name).Location; + using FileStream stream = File.OpenRead(path); + using PEReader reader = new(stream); + action(reader.GetMetadataReader(), reader); + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/ClientExperimentalDiagnosticsTests.cs b/tests/CheatEngine.Client.Tests/Architecture/ClientExperimentalDiagnosticsTests.cs new file mode 100644 index 0000000..2fbc56f --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/ClientExperimentalDiagnosticsTests.cs @@ -0,0 +1,461 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +using CheatEngine.Client.Tests.Infrastructure; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// Every experimental Client API is catalogued (plan L15): its [Experimental] diagnostic id is an entry of +/// , its UrlFormat points to the Abstractions README, that README has the id's anchor, its +/// PublicAPI entries carry the [id] prefix, and only Core, DI and tests/ suppress the diagnostic. +/// Every id is declared once, in ClientExperimentalDiagnostics, which every attribute uses, and the Client +/// libraries suppress an id in their project file, never file by file. +/// +/// +/// A lot that adds an experimental API appends one entry to and the matching README anchor; the +/// lot that lifts an id after its live qualification removes the entry together with its attributes and prefixes. +/// +public sealed partial class ClientExperimentalDiagnosticsTests +{ + private const string ExperimentalAttributeType = "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"; + + private const string ExpectedUrlFormat = + "https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/libs/CheatEngine.Client.Abstractions/README.md#{0}"; + + private const string AbstractionsReadme = "libs/CheatEngine.Client.Abstractions/README.md"; + + private const string DiagnosticsType = "CheatEngine.Client.ClientExperimentalDiagnostics"; + + private const string UrlFormatField = "UrlFormat"; + + private const int RegexTimeoutMilliseconds = 1000; + + /// The experimental Client diagnostics, one entry per id. + private static readonly ExperimentalDiagnostic[] Catalog = + [ + new("CECLIENT5001", "Value scans: IValueScanner, IValueScanSession and their request and result types"), + new("CECLIENT5002", "Target allocations: IAllocationClient, ITargetMemoryLease, AllocationRequest and AllocationProtection"), + new("CECLIENT5003", + "Instructions: ICheatEngineClient.Assembly, IAssemblyClient, AssemblyInstructionRequest, " + + "AssemblyInstructionSnapshot and InstructionEncodingPreference"), + new("CECLIENT5004", + "Auto Assembler patches: IAutoAssemblerClient, IAutoAssemblerPatchLease, AutoAssemblerCheckResult, " + + "AutoAssemblerScript and CheatEngineClientBuilder.EnableAutoAssemblerPatches") + ]; + + /// The only places that may suppress an experimental Client diagnostic. + private static readonly string[] SuppressionRoots = + [ + "libs/CheatEngine.Client.Core/", + "libs/CheatEngine.Client.Extensions.DependencyInjection/", + "tests/" + ]; + + private static readonly HashSet SkippedDirectories = new(StringComparer.OrdinalIgnoreCase) + { + "artifacts", "bin", "obj", ".git", ".vs", ".idea", "TestResults", "node_modules" + }; + + private static readonly HashSet MsBuildExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".csproj", ".props", ".targets" + }; + + private static readonly string[] MsBuildSuppressionElements = ["NoWarn", "WarningsNotAsErrors"]; + + private static readonly string[] PublicApiModifiers = + ["~", "override ", "static ", "abstract ", "virtual ", "const ", "readonly ", "sealed ", "new "]; + + private static readonly Lazy Symbols = new(ReadExperimentalSymbols, + LazyThreadSafetyMode.ExecutionAndPublication); + + [Fact] + public void EveryExperimentalClientApiUsesACataloguedIdAndTheReadmeUrl() + { + HashSet catalogued = [.. Catalog.Select(static diagnostic => diagnostic.Id)]; + string[] uncatalogued = + [ + .. Symbols.Value.Where(symbol => !catalogued.Contains(symbol.Id)) + .Select(static symbol => $"{symbol.Name} [Experimental(\"{symbol.Id}\")]") + ]; + string[] wrongUrl = + [ + .. Symbols.Value.Where(static symbol => symbol.UrlFormat != ExpectedUrlFormat) + .Select(static symbol => $"{symbol.Name}: UrlFormat = {symbol.UrlFormat ?? ""}") + ]; + string[] unused = + [ + .. catalogued.Where(id => Symbols.Value.All(symbol => symbol.Id != id)) + ]; + + Assert.True(uncatalogued.Length == 0, + "Append these experimental ids to ClientExperimentalDiagnosticsTests.Catalog:" + Environment.NewLine + + string.Join(Environment.NewLine, uncatalogued)); + Assert.True(wrongUrl.Length == 0, + $"An experimental Client API must use UrlFormat = \"{ExpectedUrlFormat}\":" + Environment.NewLine + + string.Join(Environment.NewLine, wrongUrl)); + Assert.True(unused.Length == 0, + "These catalogued ids mark no Client API; remove them from the catalog: " + string.Join(", ", unused)); + } + + [Fact] + public void EveryIdIsDeclaredOnceAndTheLibrariesUseTheDeclarationAndSuppressItPerProject() + { + Type declarations = ClientAssemblyCatalog.Load("CheatEngine.Client.Abstractions") + .GetType(DiagnosticsType, throwOnError: true)!; + FieldInfo[] constants = declarations.GetFields(BindingFlags.NonPublic | BindingFlags.Static); + string[] declared = + [ + .. constants.Where(static field => field.IsLiteral && field.Name != UrlFormatField) + .Select(static field => (string) field.GetRawConstantValue()!).Order(StringComparer.Ordinal) + ]; + + Assert.Equal(Catalog.Select(static diagnostic => diagnostic.Id).Order(StringComparer.Ordinal), declared); + Assert.Equal(ExpectedUrlFormat, + (string?) Array.Find(constants, static field => field.Name == UrlFormatField)?.GetRawConstantValue()); + List offenders = []; + foreach (string file in EnumerateScannedFiles()) + { + string relative = Path.GetRelativePath(RepositoryLayout.Root, file).Replace('\\', '/'); + if (!relative.StartsWith("libs/", StringComparison.Ordinal) || + !relative.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string text = File.ReadAllText(file); + offenders.AddRange(LiteralExperimentalArgument().Matches(text) + .Select(match => $"{relative}: {match.Value.Trim()} (use ClientExperimentalDiagnostics)")); + offenders.AddRange(FindSuppressions(relative, text) + .Select(suppression => $"{relative}: {suppression} (suppress in the project file)")); + } + + Assert.True(offenders.Count == 0, + "An [Experimental] attribute of a Client library names its id and address through the constants of " + + "ClientExperimentalDiagnostics, and Core and DI suppress an id in their project file:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Theory] + [InlineData("[Experimental(\"CECLIENT5003\", UrlFormat = ClientExperimentalDiagnostics.UrlFormat)]")] + [InlineData("[Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = \"https://example.invalid/{0}\")]")] + [InlineData("[System.Diagnostics.CodeAnalysis.ExperimentalAttribute( \"CECLIENT5004\")]")] + public void TheLiteralScanRecognizesALiteralIdOrAddress(string attribute) + { + Assert.Matches(LiteralExperimentalArgument(), attribute); + Assert.DoesNotMatch(LiteralExperimentalArgument(), + "[Experimental(ClientExperimentalDiagnostics.Instructions, UrlFormat = ClientExperimentalDiagnostics.UrlFormat)]"); + } + + [Fact] + public void EveryCataloguedIdHasItsReadmeAnchor() + { + string readme = File.ReadAllText(RepositoryLayout.Combine(AbstractionsReadme)); + + foreach (ExperimentalDiagnostic diagnostic in Catalog) + { + string anchor = $""; + int count = readme.Split(anchor).Length - 1; + Assert.True(count == 1, $"{AbstractionsReadme} must contain '{anchor}' exactly once ({diagnostic.Scope})."); + } + } + + [Fact] + public void PublicApiEntriesOfExperimentalApisCarryTheirIdPrefix() + { + List offenders = []; + Dictionary prefixed = Catalog.ToDictionary(static diagnostic => diagnostic.Id, static _ => 0, + StringComparer.Ordinal); + foreach (IGrouping assembly in Symbols.Value.GroupBy(static symbol => symbol.Assembly)) + { + foreach (string line in ReadPublicApiEntries(assembly.Key)) + { + Match prefix = IdPrefix().Match(line); + string symbolText = StripModifiers(prefix.Success ? line[prefix.Length..] : line); + ExperimentalSymbol? owner = assembly.FirstOrDefault(symbol => Declares(symbol.Name, symbolText)); + string? expected = owner?.Id; + string? actual = prefix.Success ? prefix.Groups["id"].Value : null; + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + { + offenders.Add($"{assembly.Key}: '{line}' should carry {(expected is null ? "no prefix" : $"[{expected}]")}"); + } + else if (actual is not null && prefixed.TryGetValue(actual, out int count)) + { + prefixed[actual] = count + 1; + } + } + } + + Assert.True(offenders.Count == 0, + "The PublicAPI entry of an experimental API starts with its [id], and only those entries do:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + Assert.All(prefixed, static entry => Assert.True(entry.Value > 0, $"No PublicAPI entry carries [{entry.Key}].")); + } + + [Fact] + public void OnlyCoreDependencyInjectionAndTestsSuppressAnExperimentalClientDiagnostic() + { + List suppressing = []; + foreach (string file in EnumerateScannedFiles()) + { + string relative = Path.GetRelativePath(RepositoryLayout.Root, file).Replace('\\', '/'); + if (FindSuppressions(relative, File.ReadAllText(file)).Count != 0) + { + suppressing.Add(relative); + } + } + + string[] offenders = + [ + .. suppressing.Where(static file => + !SuppressionRoots.Any(root => file.StartsWith(root, StringComparison.Ordinal))) + ]; + + // Core implements the experimental APIs, so the scan cannot pass vacuously. + Assert.Contains("libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj", suppressing); + Assert.True(offenders.Length == 0, + "Only Core, DI and tests/ may suppress an experimental Client diagnostic (CECLIENT5xxx); a consumer-facing " + + "project, the template or Fluent must not:" + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Theory] + [InlineData("Probe.cs", "#pragma warning disable CECLIENT5001")] + [InlineData("Probe.cs", "\t#pragma warning disable CS0618, CECLIENT5002 // deliberate")] + [InlineData("Probe.cs", "[SuppressMessage(\"Usage\", \"CECLIENT5001:Experimental API\", Justification = \"x\")]")] + [InlineData("Probe.props", "$(NoWarn);CECLIENT5001")] + [InlineData("Probe.csproj", + "CECLIENT5001")] + [InlineData("Probe.targets", + "")] + [InlineData(".editorconfig", "[*.cs]\ndotnet_diagnostic.CECLIENT5001.severity = none")] + [InlineData("Probe.globalconfig", "is_global = true\ndotnet_diagnostic.CECLIENT5003.severity = warning")] + [InlineData("Directory.Build.rsp", "-nowarn:CECLIENT5001")] + public void TheSuppressionScanRecognizesEverySuppressionForm(string path, string text) + { + Assert.NotEmpty(FindSuppressions(path, text)); + } + + [Theory] + [InlineData("Probe.cs", "// CECLIENT5001 marks the value scans experimental.")] + [InlineData("Probe.cs", "#pragma warning disable CS0618 // CECLIENT5001 stays an error")] + [InlineData("Probe.props", "CS1591")] + [InlineData(".editorconfig", "# dotnet_diagnostic.CECLIENT5001.severity = none is forbidden")] + public void CommentsMayCiteExperimentalClientDiagnostics(string path, string text) + { + Assert.Empty(FindSuppressions(path, text)); + } + + /// Whether a PublicAPI symbol, without prefix and modifiers, belongs to the experimental symbol. + private static bool Declares(string experimentalName, string symbolText) + { + if (!symbolText.StartsWith(experimentalName, StringComparison.Ordinal)) + { + return false; + } + + return symbolText.Length == experimentalName.Length || + symbolText[experimentalName.Length] is '.' or ' ' or '(' or '<'; + } + + private static string StripModifiers(string line) + { + string stripped = line; + bool changed = true; + while (changed) + { + changed = false; + foreach (string modifier in PublicApiModifiers) + { + if (stripped.StartsWith(modifier, StringComparison.Ordinal)) + { + stripped = stripped[modifier.Length..]; + changed = true; + } + } + } + + return stripped; + } + + private static IEnumerable ReadPublicApiEntries(string assemblyName) + { + foreach (string file in (string[]) ["PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt"]) + { + string path = RepositoryLayout.Combine($"libs/{assemblyName}/{file}"); + Assert.True(File.Exists(path), $"{assemblyName} ships experimental APIs but has no {file}."); + foreach (string line in File.ReadAllLines(path)) + { + if (line.Length != 0 && !line.StartsWith('#')) + { + yield return line; + } + } + } + } + + /// Reads every public type and member of the Client assemblies that carries [Experimental]. + private static ExperimentalSymbol[] ReadExperimentalSymbols() + { + List symbols = []; + foreach (ReflectionAssembly assembly in ClientAssemblyCatalog.LoadAll()) + { + string assemblyName = assembly.GetName().Name!; + foreach (Type type in assembly.GetExportedTypes()) + { + string typeName = type.FullName!.Replace('+', '.'); + if (TryGetExperimental(type.GetCustomAttributesData(), out string? id, out string? urlFormat)) + { + symbols.Add(new ExperimentalSymbol(assemblyName, typeName, id, urlFormat)); + } + + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + if (TryGetExperimental(member.GetCustomAttributesData(), out string? memberId, + out string? memberUrlFormat)) + { + string name = $"{typeName}.{member.Name}"; + symbols.Add(new ExperimentalSymbol(assemblyName, name, memberId, memberUrlFormat)); + } + } + } + } + + Assert.NotEmpty(symbols); + return [.. symbols]; + } + + private static bool TryGetExperimental(IList attributes, + [NotNullWhen(true)] out string? id, out string? urlFormat) + { + foreach (CustomAttributeData attribute in attributes) + { + if (attribute.AttributeType.FullName != ExperimentalAttributeType) + { + continue; + } + + id = (string) attribute.ConstructorArguments[0].Value!; + urlFormat = attribute.NamedArguments + .Where(static argument => argument.MemberName == "UrlFormat") + .Select(static argument => (string?) argument.TypedValue.Value) + .FirstOrDefault(); + return true; + } + + id = null; + urlFormat = null; + return false; + } + + /// Returns every suppression of an experimental Client diagnostic in one file. + private static List FindSuppressions(string path, string text) + { + string name = Path.GetFileName(path); + string extension = Path.GetExtension(path); + List found = []; + if (extension.Equals(".cs", StringComparison.OrdinalIgnoreCase)) + { + found.AddRange(PragmaDisable().Matches(text) + .Where(static match => ExperimentalId().IsMatch(match.Groups["ids"].Value.Split("//", 2)[0])) + .Select(static match => match.Value.Trim())); + found.AddRange(SuppressMessageAttribute().Matches(text) + .Where(static match => ExperimentalId().IsMatch(match.Groups["arguments"].Value)) + .Select(static match => match.Value.Trim())); + } + else if (MsBuildExtensions.Contains(extension)) + { + XDocument document = XDocument.Parse(text); + found.AddRange(document.Descendants() + .Where(static element => MsBuildSuppressionElements.Contains(element.Name.LocalName) && + ExperimentalId().IsMatch(element.Value)) + .Select(static element => element.Name.LocalName)); + found.AddRange(document.Descendants().Attributes() + .Where(static attribute => MsBuildSuppressionElements.Contains(attribute.Name.LocalName) && + ExperimentalId().IsMatch(attribute.Value)) + .Select(static attribute => attribute.ToString())); + } + else if (name.Equals(".editorconfig", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".globalconfig", StringComparison.OrdinalIgnoreCase)) + { + found.AddRange(SeverityConfiguration().Matches(text).Select(static match => match.Value.Trim())); + } + else if (extension.Equals(".rsp", StringComparison.OrdinalIgnoreCase)) + { + found.AddRange(ResponseFileNoWarn().Matches(text).Select(static match => match.Value.Trim())); + } + + return found; + } + + private static IEnumerable EnumerateScannedFiles() + { + Stack directories = new([RepositoryLayout.Root]); + while (directories.TryPop(out string? directory)) + { + foreach (string child in Directory.EnumerateDirectories(directory)) + { + if (!SkippedDirectories.Contains(Path.GetFileName(child))) + { + directories.Push(child); + } + } + + foreach (string file in Directory.EnumerateFiles(directory)) + { + string extension = Path.GetExtension(file); + if (extension.Equals(".cs", StringComparison.OrdinalIgnoreCase) || MsBuildExtensions.Contains(extension) || + extension.Equals(".globalconfig", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".rsp", StringComparison.OrdinalIgnoreCase) || + Path.GetFileName(file).Equals(".editorconfig", StringComparison.OrdinalIgnoreCase)) + { + yield return file; + } + } + } + } + + [GeneratedRegex(@"^\[(?CECLIENT\d{4})\]", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex IdPrefix(); + + [GeneratedRegex(@"CECLIENT5\d{3}", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ExperimentalId(); + + [GeneratedRegex(@"\bExperimental(?:Attribute)?[ \t]*\([ \t]*""[^""]*""|\bUrlFormat[ \t]*=[ \t]*""[^""]*""", + RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex LiteralExperimentalArgument(); + + [GeneratedRegex(@"^[ \t]*#[ \t]*pragma[ \t]+warning[ \t]+disable\b(?[^\r\n]*)", + RegexOptions.CultureInvariant | RegexOptions.Multiline, RegexTimeoutMilliseconds)] + private static partial Regex PragmaDisable(); + + [GeneratedRegex( + @"^[ \t]*\[[ \t]*(?:(?:assembly|module)[ \t]*:[ \t]*)?(?:global::)?(?:System\.Diagnostics\.CodeAnalysis\.)?(?:Unconditional)?SuppressMessage(?:Attribute)?[ \t]*\((?[^)]*)\)", + RegexOptions.CultureInvariant | RegexOptions.Multiline, RegexTimeoutMilliseconds)] + private static partial Regex SuppressMessageAttribute(); + + [GeneratedRegex(@"^[ \t]*dotnet_diagnostic\.CECLIENT5\d{3}\.severity[ \t]*=[^\r\n]*", + RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.IgnoreCase, RegexTimeoutMilliseconds)] + private static partial Regex SeverityConfiguration(); + + [GeneratedRegex(@"(?:^|\s)[-/]nowarn:[^\r\n]*CECLIENT5\d{3}", + RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.IgnoreCase, RegexTimeoutMilliseconds)] + private static partial Regex ResponseFileNoWarn(); + + /// One catalogued experimental diagnostic. + /// The diagnostic id, for example CECLIENT5001. + /// The APIs the id marks. + private sealed record ExperimentalDiagnostic(string Id, string Scope); + + /// One public type or member marked [Experimental]. + /// The simple name of the declaring assembly. + /// The PublicAPI name of the type, or of the member with its declaring type. + /// The diagnostic id. + /// The documentation address format, if any. + private sealed record ExperimentalSymbol(string Assembly, string Name, string Id, string? UrlFormat); +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/ClientLoggingPolicyTests.cs b/tests/CheatEngine.Client.Tests/Architecture/ClientLoggingPolicyTests.cs new file mode 100644 index 0000000..c188632 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/ClientLoggingPolicyTests.cs @@ -0,0 +1,143 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.Logging; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// Enforces the Q46 redaction policy on every source-generated log event of the shipped Client assemblies: an event +/// parameter can never carry an address, expression, path, script, message, exception, or raw failure object. +/// +public sealed partial class ClientLoggingPolicyTests +{ + private const string LoggerMessageAttributeName = "Microsoft.Extensions.Logging.LoggerMessageAttribute"; + + private static readonly Type[] SensitiveParameterTypes = + [ + typeof(Address), + typeof(SymbolExpression), + typeof(ModuleName), + typeof(CheatEngineFailure), + typeof(Exception), + typeof(LuaScript), + typeof(FileInfo), + typeof(FileSystemInfo) + ]; + + [Fact] + [Trait("Qualification", "Q46")] + public void LoggerMessageEventsNeverAcceptSensitiveParameterTypes() + { + List violations = []; + int inspectedEvents = 0; + foreach (ReflectionAssembly assembly in ClientAssemblyCatalog.LoadAll()) + { + foreach (MethodInfo method in GetLoggerMessageMethods(assembly)) + { + inspectedEvents++; + violations.AddRange(FindViolations(method)); + } + } + + Assert.True(inspectedEvents > 0, "No LoggerMessage event was found; the policy test would pass vacuously."); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + [Trait("Qualification", "Q46")] + public void LoggingPolicyRejectsAddressesFailuresExceptionsAndSensitiveStringNames() + { + MethodInfo[] fixtures = GetLoggerMessageMethods(typeof(ClientLoggingPolicyTests).Assembly) + .Where(static method => method.DeclaringType == typeof(SensitiveLoggingFixture)) + .OrderBy(static method => method.Name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + ["LeakAddress", "LeakException", "LeakFailure", "LeakPath", "SafeCounts"], + fixtures.Select(static method => method.Name)); + Assert.All(fixtures.Where(static method => method.Name.StartsWith("Leak", StringComparison.Ordinal)), + static method => Assert.NotEmpty(FindViolations(method))); + Assert.Empty(FindViolations(fixtures.Single(static method => method.Name == "SafeCounts"))); + } + + internal static IEnumerable GetLoggerMessageMethods(ReflectionAssembly assembly) + { + const BindingFlags AllDeclared = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | + BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (Type type in GetLoadableTypes(assembly)) + { + foreach (MethodInfo method in type.GetMethods(AllDeclared)) + { + if (method.GetCustomAttributesData().Any(static attribute => + attribute.AttributeType.FullName == LoggerMessageAttributeName)) + { + yield return method; + } + } + } + } + + private static IEnumerable FindViolations(MethodInfo method) + { + string owner = $"{method.DeclaringType?.FullName}.{method.Name}"; + foreach (ParameterInfo parameter in method.GetParameters()) + { + Type type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + if (typeof(ILogger).IsAssignableFrom(type) || type == typeof(LogLevel)) + { + continue; + } + + if (SensitiveParameterTypes.Any(sensitive => sensitive.IsAssignableFrom(type))) + { + yield return $"{owner} parameter '{parameter.Name}' has user-data type '{type.FullName}'."; + } + else if (type == typeof(string) && SensitiveName().IsMatch(parameter.Name ?? string.Empty)) + { + yield return $"{owner} string parameter '{parameter.Name}' is named like user data."; + } + } + } + + private static Type[] GetLoadableTypes(ReflectionAssembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + return exception.Types.Where(static type => type is not null).ToArray()!; + } + } + + [GeneratedRegex("path|file|script|source|message|expression", RegexOptions.IgnoreCase)] + private static partial Regex SensitiveName(); + + /// Deliberate violations proving that the policy test is not vacuous. Never called. + private static partial class SensitiveLoggingFixture + { + [LoggerMessage(1, LogLevel.Debug, "Leak {Address}.")] + internal static partial void LeakAddress(ILogger logger, Address address); + + [LoggerMessage(2, LogLevel.Debug, "Leak {Failure}.")] + internal static partial void LeakFailure(ILogger logger, CheatEngineFailure failure); + + [LoggerMessage(3, LogLevel.Debug, "Leak an exception.")] + internal static partial void LeakException(ILogger logger, InvalidOperationException error); + + [LoggerMessage(4, LogLevel.Debug, "Leak {TablePath}.")] + internal static partial void LeakPath(ILogger logger, string tablePath); + + [LoggerMessage(5, LogLevel.Debug, "Epoch {Epoch} attempted {Count} stage(s).")] + internal static partial void SafeCounts(ILogger logger, long epoch, int count); + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/ConsumedSdkAssemblies.cs b/tests/CheatEngine.Client.Tests/Architecture/ConsumedSdkAssemblies.cs new file mode 100644 index 0000000..94944b1 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/ConsumedSdkAssemblies.cs @@ -0,0 +1,29 @@ +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// The CheatEngine.SDK runtime assemblies that the shipped Client assemblies reference. +/// +/// They are the assemblies of the consumed package (the pin of eng/CheatEngineSdk.props), loaded from the test +/// output. The ratchets read their public surface and metadata only; no SDK code runs. +/// +internal static class ConsumedSdkAssemblies +{ + private static readonly Lazy LazyAll = new(Load, LazyThreadSafetyMode.ExecutionAndPublication); + + /// Every referenced SDK runtime assembly, ordered by simple name. + internal static IReadOnlyList All => LazyAll.Value; + + private static ReflectionAssembly[] Load() + { + return + [ + .. ClientAssemblyCatalog.LoadAll() + .SelectMany(static assembly => assembly.GetReferencedAssemblies()) + .Where(static name => name.Name is not null && MetadataSurface.IsSdkAssembly(name.Name)) + .DistinctBy(static name => name.Name, StringComparer.Ordinal) + .OrderBy(static name => name.Name, StringComparer.Ordinal) + .Select(ReflectionAssembly.Load) + ]; + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/LuaUsageScanner.cs b/tests/CheatEngine.Client.Tests/Architecture/LuaUsageScanner.cs new file mode 100644 index 0000000..024adef --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/LuaUsageScanner.cs @@ -0,0 +1,110 @@ +using System.Reflection.Metadata; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// Finds the uses of the CheatEngine.SDK Lua runtime, Lua stack and ownership surface in a Client assembly's method +/// bodies, so that each one is either sanctioned typed SDK API or registered ADR-01 debt. +/// +/// +/// +/// ADR-01: the SDK is the only native authority. The scanner no longer decides what is forbidden: it reports every +/// use of the scoped surface, and classifies each one against two exact +/// inventories. A member of SanctionedSdkLuaSurface is typed SDK API that CheatEngine.SDK imposes (its +/// admission and its owners); every other use is raw Lua or ownership work and must be a FrozenLuaUsage +/// entry. +/// +/// +/// Each use is attributed to the outermost declaring type of the method body that makes it (closures and state +/// machines fold into their container). +/// +/// +internal static class LuaUsageScanner +{ + /// Any SDK member that takes or returns a Lua state works on the Lua stack directly. + private const string LuaStateTypeName = "CheatEngine.SDK.Lua.State.LuaState"; + + /// The Lua runtime, stack, reference, marshalling and generator-helper namespaces of the SDK. + private static readonly string[] ScopedNamespaces = + [ + "CheatEngine.SDK.Lua.State.", + "CheatEngine.SDK.Lua.Runtime.", + "CheatEngine.SDK.Lua.References.", + "CheatEngine.SDK.Lua.Marshalling.", + "CheatEngine.SDK.Lua.CompilerServices." + ]; + + /// SDK types outside those namespaces that expose raw Lua errors or SDK ownership. + private static readonly string[] ScopedTypes = + [ + "CheatEngine.SDK.Lua.Calls.LuaError", + "CheatEngine.SDK.Engine.Objects.Owned`1", + "CheatEngine.SDK.Engine.Objects.StringList" + ]; + + /// The raw property, method and destroy calls of an SDK object handle. + private static readonly string[] ScopedObjectMembers = + [ + "CheatEngine.SDK.Engine.Objects.CEObject::TryCallMethod", + "CheatEngine.SDK.Engine.Objects.CEObject::TryGetProperty", + "CheatEngine.SDK.Engine.Objects.CEObject::TrySetProperty", + "CheatEngine.SDK.Engine.Objects.CEObject::TryDestroy" + ]; + + /// Returns every use of the scoped SDK surface in the assembly, sorted and without duplicates. + internal static LuaSurfaceUse[] Scan(string assemblyName) + { + SortedSet uses = new(Comparer.Create(static (left, right) => + string.CompareOrdinal(left.ToString(), right.ToString()))); + ClientAssemblyCatalog.ReadMetadata(assemblyName, (reader, peReader) => + { + foreach (MetadataSurface.IlReference reference in MetadataSurface.ReadIlReferences(reader, peReader)) + { + if (Describe(reader, reference.Token) is { } symbol && IsInScope(symbol)) + { + uses.Add(new LuaSurfaceUse(reference.OuterType, symbol)); + } + } + }); + return [.. uses]; + } + + private static string? Describe(MetadataReader reader, EntityHandle token) + { + if (MetadataSurface.AsMemberReference(reader, token) is { } member) + { + string description = MetadataSurface.DescribeMember(reader, member, + out MetadataSurface.TypeIdentity declaringType); + return declaringType.IsSdk ? description : null; + } + + if (token.Kind is HandleKind.TypeReference or HandleKind.TypeSpecification) + { + MetadataSurface.TypeIdentity type = MetadataSurface.ResolveType(reader, token); + return type.IsSdk ? type.FullName : null; + } + + return null; + } + + private static bool IsInScope(string symbol) + { + string typeName = symbol.Split("::", 2)[0]; + return ScopedNamespaces.Any(ns => typeName.StartsWith(ns, StringComparison.Ordinal)) || + ScopedTypes.Contains(typeName, StringComparer.Ordinal) || + ScopedObjectMembers.Any(member => symbol.StartsWith(member, StringComparison.Ordinal)) || + symbol.Contains(LuaStateTypeName, StringComparison.Ordinal); + } +} + +/// One use of the scoped SDK surface: the outermost Client type and the SDK symbol it references. +/// The outermost declaring type of the method body that makes the use. +/// The SDK member (Type::Name(parameters)->return) or type. +internal readonly record struct LuaSurfaceUse(string OuterType, string Symbol) +{ + /// The inventory key: OuterType -> Symbol. + public override string ToString() + { + return $"{OuterType} -> {Symbol}"; + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/MetadataSurface.cs b/tests/CheatEngine.Client.Tests/Architecture/MetadataSurface.cs new file mode 100644 index 0000000..f9884af --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/MetadataSurface.cs @@ -0,0 +1,356 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Reflection.Emit; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// Reads type and member references of a compiled assembly as stable text, and attributes the references found in +/// method bodies to their outermost declaring type. +/// +/// +/// Everything is read with : no Client or SDK code executes. Generic +/// instantiations are reduced to their definitions, so the text of a reference does not depend on the type arguments +/// a call site used. See https://learn.microsoft.com/dotnet/api/system.reflection.metadata.methodbodyblock.getilreader. +/// +internal static class MetadataSurface +{ + internal const string SdkAssemblyPrefix = "CheatEngine.SDK"; + + private static readonly Dictionary OperandTypes = BuildOperandTypes(); + + /// Gets whether an assembly simple name belongs to the consumed CheatEngine.SDK package. + internal static bool IsSdkAssembly(string assemblyName) + { + return assemblyName.StartsWith(SdkAssemblyPrefix, StringComparison.Ordinal); + } + + /// Resolves a type handle to its assembly and full name, reducing a generic instantiation to its definition. + internal static TypeIdentity ResolveType(MetadataReader reader, EntityHandle handle) + { + return handle.Kind switch + { + HandleKind.TypeReference => ResolveTypeReference(reader, (TypeReferenceHandle) handle), + HandleKind.TypeDefinition => ResolveTypeDefinition(reader, (TypeDefinitionHandle) handle), + HandleKind.TypeSpecification => reader.GetTypeSpecification((TypeSpecificationHandle) handle) + .DecodeSignature(new SignatureNameProvider(), null).Identity ?? TypeIdentity.Unresolved, + _ => TypeIdentity.Unresolved + }; + } + + /// Describes a member reference as DeclaringType::Name(parameters)->return. + internal static string DescribeMember(MetadataReader reader, MemberReferenceHandle handle, + out TypeIdentity declaringType) + { + MemberReference member = reader.GetMemberReference(handle); + declaringType = member.Parent.Kind switch + { + HandleKind.MethodDefinition => ResolveTypeDefinition(reader, + reader.GetMethodDefinition((MethodDefinitionHandle) member.Parent).GetDeclaringType()), + HandleKind.ModuleReference => TypeIdentity.Unresolved, + _ => ResolveType(reader, member.Parent) + }; + string name = reader.GetString(member.Name); + SignatureNameProvider provider = new(); + if (member.GetKind() == MemberReferenceKind.Field) + { + SignatureName fieldType = member.DecodeFieldSignature(provider, null); + return $"{declaringType.FullName}::{name}:{fieldType.Display}"; + } + + MethodSignature signature = member.DecodeMethodSignature(provider, null); + string generic = signature.GenericParameterCount == 0 ? string.Empty : $"``{signature.GenericParameterCount}"; + string parameters = string.Join(",", signature.ParameterTypes.Select(static parameter => parameter.Display)); + return $"{declaringType.FullName}::{name}{generic}({parameters})->{signature.ReturnType.Display}"; + } + + /// + /// Describes a method definition with the text gives a reference to it, so that a + /// definition read from an SDK assembly and a reference read from a Client assembly compare as equal strings. + /// + internal static string DescribeMethodDefinition(MetadataReader reader, MethodDefinitionHandle handle) + { + MethodDefinition method = reader.GetMethodDefinition(handle); + TypeIdentity declaringType = ResolveTypeDefinition(reader, method.GetDeclaringType()); + MethodSignature signature = method.DecodeSignature(new SignatureNameProvider(), null); + string generic = signature.GenericParameterCount == 0 ? string.Empty : $"``{signature.GenericParameterCount}"; + string parameters = string.Join(",", signature.ParameterTypes.Select(static parameter => parameter.Display)); + string name = reader.GetString(method.Name); + return $"{declaringType.FullName}::{name}{generic}({parameters})->{signature.ReturnType.Display}"; + } + + /// Describes a field definition with the text gives a reference to it. + internal static string DescribeFieldDefinition(MetadataReader reader, FieldDefinitionHandle handle) + { + FieldDefinition field = reader.GetFieldDefinition(handle); + TypeIdentity declaringType = ResolveTypeDefinition(reader, field.GetDeclaringType()); + SignatureName fieldType = field.DecodeSignature(new SignatureNameProvider(), null); + return $"{declaringType.FullName}::{reader.GetString(field.Name)}:{fieldType.Display}"; + } + + /// Gets the full name of the type of a custom attribute. + internal static string GetAttributeTypeName(MetadataReader reader, CustomAttribute attribute) + { + return attribute.Constructor.Kind switch + { + HandleKind.MemberReference => ResolveType(reader, + reader.GetMemberReference((MemberReferenceHandle) attribute.Constructor).Parent).FullName, + HandleKind.MethodDefinition => ResolveTypeDefinition(reader, + reader.GetMethodDefinition((MethodDefinitionHandle) attribute.Constructor).GetDeclaringType()).FullName, + _ => string.Empty + }; + } + + /// Enumerates the metadata tokens referenced by every method body, with the outermost declaring type. + /// Closures, lambdas, local functions, and state machines are nested types; they fold into their container. + internal static IEnumerable ReadIlReferences(MetadataReader reader, PEReader peReader) + { + foreach (MethodDefinitionHandle methodHandle in reader.MethodDefinitions) + { + MethodDefinition method = reader.GetMethodDefinition(methodHandle); + if (method.RelativeVirtualAddress == 0) + { + continue; + } + + string outerType = GetOutermostTypeName(reader, method.GetDeclaringType()); + string methodName = reader.GetString(method.Name); + MethodBodyBlock body = peReader.GetMethodBody(method.RelativeVirtualAddress); + foreach (EntityHandle token in ReadTokens(body.GetILReader())) + { + yield return new IlReference(outerType, methodName, token); + } + } + } + + /// Gets the full name of the outermost type that contains . + internal static string GetOutermostTypeName(MetadataReader reader, TypeDefinitionHandle handle) + { + TypeDefinitionHandle current = handle; + while (true) + { + TypeDefinitionHandle declaring = reader.GetTypeDefinition(current).GetDeclaringType(); + if (declaring.IsNil) + { + return ResolveTypeDefinition(reader, current).FullName; + } + + current = declaring; + } + } + + /// Resolves a method-body token to the member reference it names, if any. + internal static MemberReferenceHandle? AsMemberReference(MetadataReader reader, EntityHandle token) + { + return token.Kind switch + { + HandleKind.MemberReference => (MemberReferenceHandle) token, + HandleKind.MethodSpecification when reader.GetMethodSpecification((MethodSpecificationHandle) token).Method + is { Kind: HandleKind.MemberReference } method => (MemberReferenceHandle) method, + _ => null + }; + } + + internal static TypeIdentity ResolveTypeDefinition(MetadataReader reader, TypeDefinitionHandle handle) + { + TypeDefinition definition = reader.GetTypeDefinition(handle); + string name = reader.GetString(definition.Name); + TypeDefinitionHandle declaring = definition.GetDeclaringType(); + string assembly = reader.GetString(reader.GetAssemblyDefinition().Name); + if (!declaring.IsNil) + { + return new TypeIdentity(assembly, $"{ResolveTypeDefinition(reader, declaring).FullName}+{name}"); + } + + string ns = reader.GetString(definition.Namespace); + return new TypeIdentity(assembly, ns.Length == 0 ? name : $"{ns}.{name}"); + } + + private static TypeIdentity ResolveTypeReference(MetadataReader reader, TypeReferenceHandle handle) + { + TypeReference reference = reader.GetTypeReference(handle); + string name = reader.GetString(reference.Name); + EntityHandle scope = reference.ResolutionScope; + switch (scope.Kind) + { + case HandleKind.TypeReference: + { + TypeIdentity outer = ResolveTypeReference(reader, (TypeReferenceHandle) scope); + return new TypeIdentity(outer.Assembly, $"{outer.FullName}+{name}"); + } + case HandleKind.AssemblyReference: + { + string assembly = reader.GetString(reader.GetAssemblyReference((AssemblyReferenceHandle) scope).Name); + string ns = reader.GetString(reference.Namespace); + return new TypeIdentity(assembly, ns.Length == 0 ? name : $"{ns}.{name}"); + } + default: + { + string ns = reader.GetString(reference.Namespace); + string assembly = reader.GetString(reader.GetAssemblyDefinition().Name); + return new TypeIdentity(assembly, ns.Length == 0 ? name : $"{ns}.{name}"); + } + } + } + + private static List ReadTokens(BlobReader il) + { + List tokens = []; + while (il.RemainingBytes > 0) + { + ushort code = il.ReadByte(); + if (code == 0xFE) + { + code = (ushort) (0xFE00 | il.ReadByte()); + } + + switch (OperandTypes[code]) + { + case OperandType.InlineNone: + break; + case OperandType.ShortInlineBrTarget: + case OperandType.ShortInlineI: + case OperandType.ShortInlineVar: + il.ReadByte(); + break; + case OperandType.InlineVar: + il.ReadInt16(); + break; + case OperandType.InlineI: + case OperandType.InlineBrTarget: + case OperandType.ShortInlineR: + case OperandType.InlineString: + case OperandType.InlineSig: + il.ReadInt32(); + break; + case OperandType.InlineI8: + case OperandType.InlineR: + il.ReadInt64(); + break; + case OperandType.InlineSwitch: + int targets = il.ReadInt32(); + il.Offset += targets * sizeof(int); + break; + case OperandType.InlineField: + case OperandType.InlineMethod: + case OperandType.InlineTok: + case OperandType.InlineType: + tokens.Add(MetadataTokens.EntityHandle(il.ReadInt32())); + break; + default: + throw new InvalidOperationException($"Unsupported IL operand type for opcode 0x{code:X4}."); + } + } + + return tokens; + } + + private static Dictionary BuildOperandTypes() + { + Dictionary result = []; + foreach (FieldInfo field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.GetValue(null) is OpCode opcode) + { + result[unchecked((ushort) opcode.Value)] = opcode.OperandType; + } + } + + return result; + } + + /// A type identified by the simple name of its defining assembly and its full metadata name. + internal readonly record struct TypeIdentity(string Assembly, string FullName) + { + internal static TypeIdentity Unresolved => new(string.Empty, ""); + + internal bool IsSdk => IsSdkAssembly(Assembly); + } + + /// One metadata token referenced by a method body. + internal readonly record struct IlReference(string OuterType, string Method, EntityHandle Token); + + /// The display text of a decoded signature type and, for named types, their identity. + internal sealed record SignatureName(string Display, TypeIdentity? Identity); + + internal sealed class SignatureNameProvider : ISignatureTypeProvider + { + public SignatureName GetArrayType(SignatureName elementType, ArrayShape shape) + { + return new SignatureName($"{elementType.Display}[{new string(',', shape.Rank - 1)}]", null); + } + + public SignatureName GetByReferenceType(SignatureName elementType) + { + return new SignatureName(elementType.Display + "&", null); + } + + public SignatureName GetFunctionPointerType(MethodSignature signature) + { + string parameters = string.Join(",", signature.ParameterTypes.Select(static parameter => parameter.Display)); + return new SignatureName($"fnptr({parameters})->{signature.ReturnType.Display}", null); + } + + public SignatureName GetGenericInstantiation(SignatureName genericType, ImmutableArray typeArguments) + { + string arguments = string.Join(",", typeArguments.Select(static argument => argument.Display)); + return new SignatureName($"{genericType.Display}<{arguments}>", genericType.Identity); + } + + public SignatureName GetGenericMethodParameter(object? genericContext, int index) + { + return new SignatureName($"!!{index}", null); + } + + public SignatureName GetGenericTypeParameter(object? genericContext, int index) + { + return new SignatureName($"!{index}", null); + } + + public SignatureName GetModifiedType(SignatureName modifier, SignatureName unmodifiedType, bool isRequired) + { + return unmodifiedType; + } + + public SignatureName GetPinnedType(SignatureName elementType) + { + return elementType; + } + + public SignatureName GetPointerType(SignatureName elementType) + { + return new SignatureName(elementType.Display + "*", null); + } + + public SignatureName GetPrimitiveType(PrimitiveTypeCode typeCode) + { + return new SignatureName(typeCode.ToString().ToLowerInvariant(), null); + } + + public SignatureName GetSZArrayType(SignatureName elementType) + { + return new SignatureName(elementType.Display + "[]", null); + } + + public SignatureName GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + TypeIdentity identity = ResolveTypeDefinition(reader, handle); + return new SignatureName(identity.FullName, identity); + } + + public SignatureName GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + TypeIdentity identity = ResolveTypeReference(reader, handle); + return new SignatureName(identity.FullName, identity); + } + + public SignatureName GetTypeFromSpecification(MetadataReader reader, object? genericContext, + TypeSpecificationHandle handle, byte rawTypeKind) + { + return reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + } + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/OperationNameTests.cs b/tests/CheatEngine.Client.Tests/Architecture/OperationNameTests.cs new file mode 100644 index 0000000..f06109d --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/OperationNameTests.cs @@ -0,0 +1,226 @@ +#pragma warning disable CECLIENT5003 // The operation names cover the experimental instruction client. +#pragma warning disable CECLIENT5004 // The operation names cover the experimental Auto Assembler client. + +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// Every failure operation name that a checked Client library writes is <Service>.<Member>, as +/// documents: Service is the +/// property that exposes the service (UnsafeLua or AutoAssembler for +/// the services only dependency injection registers, Client for the activation itself), and Member is +/// one of its public methods without Try or Detailed, or Release for a lease. +/// +/// +/// The names are read from the metadata of each checked library: every string literal of its code (the user-string +/// heap) and every string constant, so a name written inline or through a constant is checked alike, and no Client +/// code runs. Any literal of the shape Word.Word is an operation name. +/// +public sealed partial class OperationNameTests +{ + private const string ClientService = "Client"; + private const string ReleaseMember = "Release"; + private const string TryPrefix = "Try"; + private const string DetailedSuffix = "Detailed"; + + /// + /// The member of whose refusal is reported under Client. + /// + private const string PluginClientMember = "GetRequiredClient"; + + /// + /// The Client libraries checked, read as metadata. Abstractions and dependency injection write no operation + /// name: their Word.Word literals are capability ids and option paths. Fluent's pattern terminals report + /// the scan they run (Patterns.Scan). + /// + private static readonly string[] CheckedAssemblies = + ["CheatEngine.Client.Core", "CheatEngine.Client.Fluent", "CheatEngine.Client.Hosting"]; + + /// The services, by the name their failures carry, with the public contracts whose methods they report. + private static readonly Dictionary Services = new(StringComparer.Ordinal) + { + ["Runtime"] = [typeof(ICheatEngineRuntime)], + ["Dispatcher"] = [typeof(ICheatEngineDispatcher)], + ["Processes"] = [typeof(IProcessClient)], + ["Memory"] = [typeof(IMemoryClient)], + ["Patterns"] = [typeof(IPatternScanner)], + ["ValueScans"] = [typeof(IValueScanner), typeof(IValueScanSession)], + ["Allocations"] = [typeof(IAllocationClient)], + ["Inspection"] = [typeof(IInspectionClient)], + ["Tables"] = [typeof(ITableClient)], + ["Lua"] = [typeof(ILuaClient)], + ["Assembly"] = [typeof(IAssemblyClient)], + ["UnsafeLua"] = [typeof(IUnsafeLuaClient)], + ["AutoAssembler"] = [typeof(IAutoAssemblerClient)] + }; + + /// The services that only dependency injection registers: no property. + private static readonly string[] RegisteredOnly = ["UnsafeLua", "AutoAssembler"]; + + /// + /// The operations of the activation itself, reported under Client: its steps, and the plugin member that + /// hands out the client of the active activation. + /// + private static readonly string[] ClientOperations = + ["Activate", "DrainResources", "EnterCleanupScope", PluginClientMember, "TrackResource"]; + + [Fact] + public void EveryServiceNameIsTheClientPropertyThatExposesIt() + { + foreach ((string service, Type[] contracts) in Services) + { + PropertyInfo? property = typeof(ICheatEngineClient).GetProperty(service); + if (RegisteredOnly.Contains(service, StringComparer.Ordinal)) + { + Assert.True(property is null, $"{service} is registered by dependency injection only."); + continue; + } + + Assert.True(property is not null, $"ICheatEngineClient has no {service} property."); + Assert.Equal(contracts[0], property.PropertyType); + } + } + + [Fact] + public void ThePluginMemberReportedUnderClientIsTheOneAPluginCalls() + { + MethodInfo? method = typeof(CheatEngineClientPlugin).GetMethod(PluginClientMember, + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + Assert.True(method.IsFamily); + } + + [Fact] + public void EveryOperationNameIsAServiceAndOneOfItsPublicMembers() + { + Dictionary> names = new(StringComparer.Ordinal); + List offenders = []; + foreach (string assembly in CheckedAssemblies) + { + names[assembly] = []; + foreach (string name in ReadOperationNames(assembly)) + { + names[assembly].Add(name); + if (!IsValid(name)) + { + offenders.Add($"{assembly}: {name}"); + } + } + } + + // The scan cannot pass vacuously, library by library: Core names written inline and through constants are + // present, and so are the Fluent and Hosting names. + Assert.Contains("Patterns.Scan", names["CheatEngine.Client.Core"]); + Assert.Contains("Memory.Read", names["CheatEngine.Client.Core"]); + Assert.Contains("Allocations.Release", names["CheatEngine.Client.Core"]); + Assert.Contains("Patterns.Scan", names["CheatEngine.Client.Fluent"]); + Assert.Contains("Client.GetRequiredClient", names["CheatEngine.Client.Hosting"]); + Assert.True(offenders.Count == 0, + "A Client operation name must be .: a service of ICheatEngineClient (or UnsafeLua, " + + "AutoAssembler, Client) and one of its public methods without Try or Detailed, or Release:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Theory] + [InlineData("TryReadBytes", "ReadBytes")] + [InlineData("ReadBytesDetailed", "ReadBytes")] + [InlineData("Scan", "Scan")] + public void AMemberNameDropsTryAndDetailed(string method, string expected) + { + Assert.Equal(expected, MemberName(method)); + } + + private static bool IsValid(string name) + { + Match match = OperationName().Match(name); + string service = match.Groups["service"].Value; + string member = match.Groups["member"].Value; + return service == ClientService + ? ClientOperations.Contains(member, StringComparer.Ordinal) + : Services.TryGetValue(service, out Type[]? contracts) && + (member == ReleaseMember || contracts.Any(contract => PublicMembers(contract).Contains(member))); + } + + /// + /// Reads every string literal and string constant of one library that has the shape of an operation name. + /// + private static string[] ReadOperationNames(string assembly) + { + HashSet names = new(StringComparer.Ordinal); + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + if (reader.GetHeapSize(HeapIndex.UserString) > 1) + { + for (UserStringHandle handle = MetadataTokens.UserStringHandle(1); + !handle.IsNil; + handle = reader.GetNextHandle(handle)) + { + Add(reader.GetUserString(handle)); + } + } + + for (int row = 1; row <= reader.GetTableRowCount(TableIndex.Constant); row++) + { + Constant constant = reader.GetConstant(MetadataTokens.ConstantHandle(row)); + if (constant.TypeCode == ConstantTypeCode.String) + { + BlobReader value = reader.GetBlobReader(constant.Value); + Add(value.ReadConstant(ConstantTypeCode.String) as string); + } + } + }); + + return [.. names.Order(StringComparer.Ordinal)]; + + void Add(string? value) + { + if (value is not null && OperationName().IsMatch(value)) + { + _ = names.Add(value); + } + } + } + + /// Gets the member names a contract exposes: its methods and those of the contracts it extends. + private static HashSet PublicMembers(Type contract) + { + return + [ + .. contract.GetInterfaces().Prepend(contract) + .SelectMany(static type => type.GetMethods(BindingFlags.Public | BindingFlags.Instance)) + .Where(static method => !method.IsSpecialName) + .Select(static method => MemberName(method.Name)) + ]; + } + + private static string MemberName(string method) + { + string member = method.StartsWith(TryPrefix, StringComparison.Ordinal) && method.Length > TryPrefix.Length && + char.IsUpper(method[TryPrefix.Length]) + ? method[TryPrefix.Length..] + : method; + return member.EndsWith(DetailedSuffix, StringComparison.Ordinal) + ? member[..^DetailedSuffix.Length] + : member; + } + + [GeneratedRegex(@"^(?[A-Z][A-Za-z]*)\.(?[A-Z][A-Za-z]*)$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex OperationName(); +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/OutcomeEnumConventionTests.cs b/tests/CheatEngine.Client.Tests/Architecture/OutcomeEnumConventionTests.cs new file mode 100644 index 0000000..d4c2624 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/OutcomeEnumConventionTests.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Tests.Infrastructure; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// The enum charter of the 1.x public surface: every public enum of a shipped Client assembly is backed by +/// , declares every value explicitly and defines zero, and is exactly one of two kinds. An outcome +/// enum reports what happened or was observed: its name ends in Kind, Status, State, +/// Effect or Scope (or it is listed in ) and it has +/// Unknown = 0, so a value that was never assigned never reads as an outcome. An option enum is the caller's +/// choice: it is listed in , its zero is a valid default choice and its name never ends in +/// an outcome suffix. No enum is named *Outcome: that suffix names a result object. +/// +/// +/// +/// The enum types are read from the compiled assemblies; whether a value is explicit is read from its +/// declaration under libs/ or src/. The classification is total: a new enum that is neither +/// fails until it is named or listed. +/// +/// +/// lists the enums that break the charter today, each with the rule it breaks +/// and the lot that fixes it. It is empty and may only stay so: fix the enum instead of adding it. +/// +/// +public sealed partial class OutcomeEnumConventionTests +{ + private const int RegexTimeoutMilliseconds = 1000; + + private static readonly string[] OutcomeSuffixes = ["Kind", "Status", "State", "Effect", "Scope"]; + + /// Outcome enums whose name does not end in an outcome suffix, with why the name stays. + private static readonly Dictionary OutcomeEnumsWithoutSuffix = new(StringComparer.Ordinal) + { + ["CheatEngine.Client.Runtime.ClientCapabilityEvidenceReasonCode"] = + "EffectiveReasonCode pairs with EffectiveReason: the code and the text of the same reason.", + ["CheatEngine.Client.Scanning.PatternScanRouteReason"] = + "RouteReason says why a pattern scan took its route; a reason is not a kind of route." + }; + + /// Option enums: zero is a valid, documented choice, never Unknown. + private static readonly Dictionary OptionEnums = new(StringComparer.Ordinal) + { + ["CheatEngine.Client.Allocations.AllocationProtection"] = + "ReadWrite (0) is the default protection of an allocation request.", + ["CheatEngine.Client.Assembly.InstructionEncodingPreference"] = + "None (0) lets Cheat Engine choose the encoding of an assembled instruction.", + ["CheatEngine.Client.Inspection.AddressResolutionMode"] = + "Default (0) is Cheat Engine's ordinary address resolution.", + ["CheatEngine.Client.Memory.MemoryStringEncoding"] = "Utf8 (0) is the default encoding of a string request.", + ["CheatEngine.Client.Scanning.ScanAlignmentMode"] = + "None (0) is the default alignment rule: every address is checked.", + ["CheatEngine.Client.Scanning.ScanProtectionRequirement"] = + "Unspecified (0) leaves the flag out of the protection filter, the default of every flag.", + ["CheatEngine.Client.Scanning.ValueScanComparison"] = + "Exact (0) is a valid comparison; a request names its comparison through its factory.", + ["CheatEngine.Client.Scanning.ValueScanValueType"] = + "Integer8 (0) is a valid value type; a request takes it from its value or its factory." + }; + + /// Enums that break the charter today, with the rule they break and the lot that fixes them. + private static readonly Dictionary PendingCharterEnums = new(StringComparer.Ordinal); + + [Fact] + public void PublicEnumsFollowTheCharterOrAreStillPending() + { + Dictionary> violations = FindViolations(); + + string[] added = + [ + .. violations.Keys.Except(PendingCharterEnums.Keys, StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Select(name => $"{name}: {string.Join("; ", violations[name])}") + ]; + string[] resolved = + [.. PendingCharterEnums.Keys.Except(violations.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal)]; + Assert.True(added.Length == 0, + "Follow the enum charter (int, explicit values, a zero value, Unknown = 0 on an outcome enum) instead of " + + "adding to PendingCharterEnums:" + Environment.NewLine + string.Join(Environment.NewLine, added)); + Assert.True(resolved.Length == 0, + "These enums follow the charter now; remove them from PendingCharterEnums: " + string.Join(", ", resolved)); + } + + [Fact] + public void TheCharterInspectsEveryShippedPublicEnum() + { + string[] names = [.. PublicEnums().Select(static type => type.FullName!)]; + + // The results vocabulary must be among the inspected enums, or the charter would pass vacuously. + Assert.Contains("CheatEngine.Client.Results.CheatEngineFailureKind", names); + Assert.Contains("CheatEngine.Client.Results.CheatEngineHostEffect", names); + Assert.Contains("CheatEngine.Client.Results.LeaseReleaseKind", names); + Assert.All(PendingCharterEnums.Keys, name => Assert.Contains(name, names)); + } + + [Fact] + public void PendingCharterEnumsIsEmpty() + { + Assert.Empty(PendingCharterEnums); + } + + [Fact] + public void OutcomeEnumsWithoutSuffixExistAndDefineUnknownZero() + { + Dictionary enums = PublicEnums().ToDictionary(static type => type.FullName!, + StringComparer.Ordinal); + + foreach (string name in OutcomeEnumsWithoutSuffix.Keys) + { + Assert.True(enums.TryGetValue(name, out Type? type), $"The outcome enum {name} is not a shipped public enum."); + Assert.Equal("Unknown", Enum.GetName(type, Enum.ToObject(type, 0))); + } + } + + [Fact] + public void OptionEnumsExistAndDefineAValidZero() + { + Dictionary enums = PublicEnums().ToDictionary(static type => type.FullName!, + StringComparer.Ordinal); + + foreach (string name in OptionEnums.Keys) + { + Assert.True(enums.TryGetValue(name, out Type? type), $"The option enum {name} is not a shipped public enum."); + string? zero = Enum.GetName(type, Enum.ToObject(type, 0)); + Assert.False(zero is null, $"The option enum {name} defines no zero value."); + Assert.NotEqual("Unknown", zero); + } + } + + /// Finds every public enum that breaks the charter, with the rules it breaks. + private static Dictionary> FindViolations() + { + Dictionary sources = ReadEnumDeclarations(); + Dictionary> violations = new(StringComparer.Ordinal); + foreach (Type type in PublicEnums()) + { + List rules = []; + Type underlying = Enum.GetUnderlyingType(type); + if (underlying != typeof(int)) + { + rules.Add($"backed by {underlying.Name}, not Int32"); + } + + if (!sources.TryGetValue(type.Name, out string? body)) + { + rules.Add("declaration not found under libs/ or src/"); + } + else + { + string[] implicitValues = + [ + .. Enum.GetNames(type).Where(name => + !Regex.IsMatch(body, $@"\b{Regex.Escape(name)}\s*=", RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(RegexTimeoutMilliseconds))) + ]; + if (implicitValues.Length != 0) + { + rules.Add($"implicit value for {string.Join(", ", implicitValues)}"); + } + } + + string? zero = Enum.GetName(type, Enum.ToObject(type, 0)); + if (zero is null) + { + rules.Add("no zero value"); + } + + bool hasOutcomeSuffix = + OutcomeSuffixes.Any(suffix => type.Name.EndsWith(suffix, StringComparison.Ordinal)); + bool isOption = OptionEnums.ContainsKey(type.FullName!); + bool isOutcome = hasOutcomeSuffix || OutcomeEnumsWithoutSuffix.ContainsKey(type.FullName!); + if (type.Name.EndsWith("Outcome", StringComparison.Ordinal)) + { + rules.Add("named *Outcome, which names a result object, never an enum"); + } + + if (isOption && isOutcome) + { + rules.Add("listed as an option enum, but its name or listing makes it an outcome enum"); + } + else if (!isOption && !isOutcome) + { + rules.Add("neither an outcome enum (outcome suffix, Unknown = 0) nor a listed option enum"); + } + else if (isOutcome && zero != "Unknown") + { + rules.Add(Enum.GetNames(type).Contains("Unknown", StringComparer.Ordinal) + ? $"Unknown is {Convert.ToInt64(Enum.Parse(type, "Unknown"), CultureInfo.InvariantCulture)}, not 0" + : "no Unknown = 0 on an outcome enum"); + } + + if (rules.Count != 0) + { + violations.Add(type.FullName!, rules); + } + } + + return violations; + } + + private static IEnumerable PublicEnums() + { + foreach (ReflectionAssembly assembly in ClientAssemblyCatalog.LoadAll()) + { + foreach (Type type in assembly.GetExportedTypes()) + { + if (type.IsEnum) + { + yield return type; + } + } + } + } + + /// Reads the body of every enum declared under libs/ and src/, keyed by simple name. + private static Dictionary ReadEnumDeclarations() + { + Dictionary bodies = new(StringComparer.Ordinal); + foreach (string root in (string[]) ["libs", "src"]) + { + string directory = RepositoryLayout.Combine(root); + foreach (string file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(directory, file); + if (relative.Split(Path.DirectorySeparatorChar).Any(static part => part is "bin" or "obj")) + { + continue; + } + + string text = LineComment().Replace(File.ReadAllText(file), string.Empty); + foreach (Match declaration in EnumDeclaration().Matches(text)) + { + bodies[declaration.Groups["name"].Value] = declaration.Groups["body"].Value; + } + } + } + + return bodies; + } + + [GeneratedRegex(@"\benum\s+(?\w+)(?:\s*:\s*\w+)?\s*\{(?[^}]*)\}", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex EnumDeclaration(); + + [GeneratedRegex(@"//[^\r\n]*", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex LineComment(); +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/PublicApiCharterTests.cs b/tests/CheatEngine.Client.Tests/Architecture/PublicApiCharterTests.cs new file mode 100644 index 0000000..66a70dc --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/PublicApiCharterTests.cs @@ -0,0 +1,461 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Reflection.Metadata; +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Results; +using CheatEngine.Client.SourceGenerators.Lua; + + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// The mechanical rules of the public API charter (Abstractions README, "Public API charter") over every shipped +/// Client assembly: no CheatEngine.SDK outcome, status or kind type in a public signature, the Try and throwing +/// form pairs and their parameter order, no enum named *Outcome, no settable public struct, no record struct +/// that compares an by reference, no public Client exception constructor, and the +/// final list of the CheatEngine.SDK value types allowed in public signatures. +/// +/// +/// The SDK-type rule reads the compiled metadata with : every type in the +/// signature of every public or protected member, generic arguments included, is inspected and no Client code runs. +/// The other rules read the loaded public types. +/// +public sealed class PublicApiCharterTests +{ + private const string TryPrefix = "Try"; + + private static readonly string[] SdkOutcomeSuffixes = ["Outcome", "Status", "Kind"]; + + /// + /// The CheatEngine.SDK value types allowed in public signatures; moving to CheatEngine.SDK 3.0 is a Client 2.0. + /// + private static readonly string[] FinalApprovedSdkValueTypes = + [ + "CheatEngine.SDK.Engine.AddressList.MemoryRecordId", + "CheatEngine.SDK.Engine.Enums.VariableType", + "CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo", + "CheatEngine.SDK.Engine.Inspection.ModuleInfo", + "CheatEngine.SDK.Engine.Inspection.ModuleName", + "CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo", + "CheatEngine.SDK.Engine.Inspection.SymbolExpression", + "CheatEngine.SDK.Engine.Inspection.SymbolInfo", + "CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem", + "CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", + "CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.SDK.Engine.Runtime.TargetAbi", + "CheatEngine.SDK.Engine.Runtime.TargetBackend", + "CheatEngine.SDK.Engine.Values.Address" + ]; + + /// + /// The Try members exempt from the twin and token rules, by declaring type and name: BCL-shaped pure lookups + /// and parses, which have no failure output, implementable callbacks and the codec contexts' accessors. + /// + private static readonly HashSet TryFormExemptions = new(StringComparer.Ordinal) + { + "CheatEngine.Client.Runtime.ClientCapabilities.TryGet", + "CheatEngine.Client.Scanning.AobPattern.TryParse", + "CheatEngine.Client.Lua.ILuaOperation`1.TryExecute", + "CheatEngine.Client.Memory.IMemoryCodec`1.TryRead", + "CheatEngine.Client.Memory.IMemoryCodec`1.TryWrite", + "CheatEngine.Client.Memory.IMemoryReadContext.TryReadBytes", + "CheatEngine.Client.Memory.IMemoryWriteContext.TryWriteBytes" + }; + + [Fact] + public void NoCheatEngineSdkOutcomeStatusOrKindTypeAppearsInAPublicSignature() + { + List offenders = []; + int inspected = 0; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach ((string member, ImmutableArray types) in + ReadPublicSignatures(reader)) + { + inspected++; + offenders.AddRange(types + .Where(static type => type.IsSdk && SdkOutcomeSuffixes.Any(suffix => + type.FullName.EndsWith(suffix, StringComparison.Ordinal))) + .Select(type => $"{assembly}: {member} uses {type.FullName}")); + } + }); + } + + Assert.True(inspected > 500, $"Only {inspected} public signatures were inspected."); + Assert.True(offenders.Count == 0, + "A public signature exposes a CheatEngine.SDK outcome, status or kind type; map it to a Client value:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders.Distinct(StringComparer.Ordinal))); + } + + [Fact] + public void EveryTryFormHasItsThrowingTwinAndTheCharterParameterOrder() + { + List offenders = []; + int pairs = 0; + foreach (Type type in PublicTypes()) + { + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + if (!IsTryForm(method)) + { + continue; + } + + string name = $"{type.FullName}.{method.Name}"; + ParameterInfo[] parameters = method.GetParameters(); + offenders.AddRange(CheckParameterOrder(name, parameters)); + if (TryFormExemptions.Contains(name)) + { + continue; + } + + if (!HasThrowingTwin(type, method)) + { + offenders.Add($"{name}: no public {method.Name[TryPrefix.Length..]} with the same inputs"); + } + else + { + pairs++; + } + } + } + + Assert.True(pairs > 50, $"Only {pairs} Try and throwing pairs were found."); + Assert.True(offenders.Count == 0, + "A TryX form returns bool with its value and 'out CheatEngineFailure failure' last among the outputs, then " + + "an optional CancellationToken; a throwing X form with the same inputs exists in the same type:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void NoPublicEnumIsNamedOutcome() + { + string[] offenders = + [ + .. PublicTypes().Where(static type => type.IsEnum && type.Name.EndsWith("Outcome", StringComparison.Ordinal)) + .Select(static type => type.FullName!) + ]; + + Assert.True(offenders.Length == 0, + "'*Outcome' names a result object, never an enum: " + string.Join(", ", offenders)); + } + + [Fact] + public void NoPublicStructHasAnInitOrSetAccessor() + { + string[] offenders = + [ + .. PublicTypes().Where(static type => type is { IsValueType: true, IsEnum: false }) + .SelectMany(static type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(static property => property.SetMethod is { IsPublic: true } or { IsFamily: true }) + .Select(property => $"{type.FullName}.{property.Name}")) + ]; + + Assert.True(offenders.Length == 0, + "A public value type exposes get-only properties set by its constructor: " + string.Join(", ", offenders)); + } + + [Fact] + public void NoPublicRecordStructHoldsAnImmutableArray() + { + string[] records = [.. PublicTypes().Where(IsRecordStruct).Select(static type => type.FullName!)]; + string[] offenders = + [ + .. PublicTypes().Where(IsRecordStruct) + .SelectMany(static type => type + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(static field => field.FieldType.IsGenericType && + field.FieldType.GetGenericTypeDefinition() == typeof(ImmutableArray<>)) + .Select(field => $"{type.FullName}.{field.Name}")) + ]; + + // Record structs remain for the value types whose member-wise equality is meaningful; CheatEngineFailure compares + // its Exception by reference, as the charter says. + Assert.Contains("CheatEngine.Client.Results.CheatEngineFailure", records); + Assert.True(offenders.Length == 0, + "A record struct compares an ImmutableArray by reference; make the type a plain readonly struct: " + + string.Join(", ", offenders)); + } + + [Fact] + public void NoClientExceptionHasAPublicOrProtectedConstructor() + { + Type[] exceptions = [.. PublicTypes().Where(static type => typeof(Exception).IsAssignableFrom(type))]; + string[] offenders = + [ + .. exceptions.SelectMany(static type => type + .GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(static constructor => constructor.IsPublic || constructor.IsFamily || + constructor.IsFamilyOrAssembly) + .Select(constructor => $"{type.FullName}({string.Join(", ", + constructor.GetParameters().Select(static parameter => parameter.ParameterType.Name))})")) + ]; + + Assert.Contains(typeof(CheatEngineInvalidStateException), exceptions); + Assert.True(offenders.Length == 0, + "A Client exception is created only by CheatEngineFailure.Throw or ToException: " + + string.Join(", ", offenders)); + } + + [Fact] + public void TheApprovedSdkValueTypesAreTheFinalSixteen() + { + Assert.Equal(FinalApprovedSdkValueTypes, ApprovedSdkClientTypes.Names); + } + + private static bool IsTryForm(MethodInfo method) + { + return method.Name.Length > TryPrefix.Length && method.Name.StartsWith(TryPrefix, StringComparison.Ordinal) && + char.IsUpper(method.Name[TryPrefix.Length]) && method.ReturnType == typeof(bool) && + !method.IsSpecialName; + } + + /// + /// Checks the order the charter fixes: inputs, then the out outputs with out CheatEngineFailure + /// failure last among them, then an optional , always last. + /// + private static IEnumerable CheckParameterOrder(string name, ParameterInfo[] parameters) + { + int firstOut = Array.FindIndex(parameters, static parameter => parameter.IsOut); + int token = Array.FindIndex(parameters, static parameter => parameter.ParameterType == typeof(CancellationToken)); + int failure = Array.FindIndex(parameters, + static parameter => parameter.IsOut && parameter.ParameterType.GetElementType() == typeof(CheatEngineFailure)); + if (token >= 0 && token != parameters.Length - 1) + { + yield return $"{name}: the CancellationToken is not the last parameter"; + } + + if (firstOut >= 0 && parameters.Take(token >= 0 ? token : parameters.Length).Skip(firstOut) + .Any(static parameter => !parameter.IsOut)) + { + yield return $"{name}: an input follows an out parameter"; + } + + if (failure >= 0) + { + int lastOut = Array.FindLastIndex(parameters, static parameter => parameter.IsOut); + if (failure != lastOut) + { + yield return $"{name}: 'out CheatEngineFailure' is not the last output"; + } + + if (parameters[failure].Name != "failure") + { + yield return $"{name}: the failure output is named '{parameters[failure].Name}', not 'failure'"; + } + } + } + + /// Whether the declaring type has a public throwing form with the same inputs as the Try form. + private static bool HasThrowingTwin(Type type, MethodInfo tryForm) + { + string twinName = tryForm.Name[TryPrefix.Length..]; + Type[] inputs = Inputs(tryForm); + return type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) + .Where(candidate => candidate.Name == twinName && + candidate.GetGenericArguments().Length == tryForm.GetGenericArguments().Length) + .Any(candidate => Inputs(candidate).Select(Normalize).SequenceEqual(inputs.Select(Normalize))); + + static Type[] Inputs(MethodInfo method) + { + return + [ + .. method.GetParameters() + .Where(static parameter => !parameter.IsOut && parameter.ParameterType != typeof(CancellationToken)) + .Select(static parameter => parameter.ParameterType) + ]; + } + + static string Normalize(Type type) + { + // A generic method parameter compares by position, whatever the declaring method. + return type.IsGenericMethodParameter + ? $"!!{type.GenericParameterPosition}" + : type.ContainsGenericParameters + ? type.Name + : type.FullName ?? type.Name; + } + } + + private static bool IsRecordStruct(Type type) + { + return type is { IsValueType: true, IsEnum: false } && + type.GetMethod("PrintMembers", BindingFlags.Instance | BindingFlags.NonPublic) is { } printMembers && + printMembers.IsDefined(typeof(CompilerGeneratedAttribute)); + } + + private static IEnumerable PublicTypes() + { + return ClientAssemblyCatalog.LoadAll().SelectMany(static assembly => assembly.GetExportedTypes()); + } + + /// Reads the signature types of every public or protected member of every visible type. + private static IEnumerable<(string Member, ImmutableArray Types)> ReadPublicSignatures( + MetadataReader reader) + { + TypeCollector collector = new(); + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) + { + if (!IsVisible(reader, typeHandle)) + { + continue; + } + + TypeDefinition type = reader.GetTypeDefinition(typeHandle); + string typeName = MetadataSurface.ResolveTypeDefinition(reader, typeHandle).FullName; + if (!type.BaseType.IsNil) + { + yield return ($"{typeName} base type", Collect(reader, collector, type.BaseType)); + } + + foreach (InterfaceImplementationHandle implementation in type.GetInterfaceImplementations()) + { + yield return ($"{typeName} interface", + Collect(reader, collector, reader.GetInterfaceImplementation(implementation).Interface)); + } + + foreach (MethodDefinitionHandle methodHandle in type.GetMethods()) + { + MethodDefinition method = reader.GetMethodDefinition(methodHandle); + MethodAttributes access = method.Attributes & MethodAttributes.MemberAccessMask; + if (access is MethodAttributes.Public or MethodAttributes.Family or MethodAttributes.FamORAssem) + { + MethodSignature> signature = + method.DecodeSignature(collector, null); + yield return ($"{typeName}.{reader.GetString(method.Name)}", + [.. signature.ReturnType, .. signature.ParameterTypes.SelectMany(static types => types)]); + } + } + + foreach (FieldDefinitionHandle fieldHandle in type.GetFields()) + { + FieldDefinition field = reader.GetFieldDefinition(fieldHandle); + FieldAttributes access = field.Attributes & FieldAttributes.FieldAccessMask; + if (access is FieldAttributes.Public or FieldAttributes.Family or FieldAttributes.FamORAssem) + { + yield return ($"{typeName}.{reader.GetString(field.Name)}", field.DecodeSignature(collector, null)); + } + } + } + } + + private static ImmutableArray Collect(MetadataReader reader, TypeCollector collector, + EntityHandle handle) + { + return handle.Kind switch + { + HandleKind.TypeDefinition => collector.GetTypeFromDefinition(reader, (TypeDefinitionHandle) handle, 0), + HandleKind.TypeReference => collector.GetTypeFromReference(reader, (TypeReferenceHandle) handle, 0), + HandleKind.TypeSpecification => collector.GetTypeFromSpecification(reader, null, + (TypeSpecificationHandle) handle, 0), + _ => [] + }; + } + + /// Whether a type definition is visible outside its assembly: public, or nested public in a visible type. + private static bool IsVisible(MetadataReader reader, TypeDefinitionHandle handle) + { + TypeDefinition type = reader.GetTypeDefinition(handle); + TypeAttributes visibility = type.Attributes & TypeAttributes.VisibilityMask; + return visibility switch + { + TypeAttributes.Public => true, + TypeAttributes.NestedPublic or TypeAttributes.NestedFamily or TypeAttributes.NestedFamORAssem => + IsVisible(reader, type.GetDeclaringType()), + _ => false + }; + } + + /// Collects every named type of a signature, generic arguments and element types included. + private sealed class TypeCollector : ISignatureTypeProvider, object?> + { + public ImmutableArray GetArrayType( + ImmutableArray elementType, ArrayShape shape) + { + return elementType; + } + + public ImmutableArray GetByReferenceType( + ImmutableArray elementType) + { + return elementType; + } + + public ImmutableArray GetFunctionPointerType( + MethodSignature> signature) + { + return [.. signature.ReturnType, .. signature.ParameterTypes.SelectMany(static types => types)]; + } + + public ImmutableArray GetGenericInstantiation( + ImmutableArray genericType, + ImmutableArray> typeArguments) + { + return [.. genericType, .. typeArguments.SelectMany(static types => types)]; + } + + public ImmutableArray GetGenericMethodParameter(object? genericContext, int index) + { + return []; + } + + public ImmutableArray GetGenericTypeParameter(object? genericContext, int index) + { + return []; + } + + public ImmutableArray GetModifiedType( + ImmutableArray modifier, + ImmutableArray unmodifiedType, bool isRequired) + { + return unmodifiedType; + } + + public ImmutableArray GetPinnedType( + ImmutableArray elementType) + { + return elementType; + } + + public ImmutableArray GetPointerType( + ImmutableArray elementType) + { + return elementType; + } + + public ImmutableArray GetPrimitiveType(PrimitiveTypeCode typeCode) + { + return []; + } + + public ImmutableArray GetSZArrayType( + ImmutableArray elementType) + { + return elementType; + } + + public ImmutableArray GetTypeFromDefinition(MetadataReader reader, + TypeDefinitionHandle handle, byte rawTypeKind) + { + return [MetadataSurface.ResolveTypeDefinition(reader, handle)]; + } + + public ImmutableArray GetTypeFromReference(MetadataReader reader, + TypeReferenceHandle handle, byte rawTypeKind) + { + return [MetadataSurface.ResolveType(reader, handle)]; + } + + public ImmutableArray GetTypeFromSpecification(MetadataReader reader, + object? genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + { + return reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + } + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/PublicApiDocumentationTests.cs b/tests/CheatEngine.Client.Tests/Architecture/PublicApiDocumentationTests.cs new file mode 100644 index 0000000..2d1b19a --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/PublicApiDocumentationTests.cs @@ -0,0 +1,767 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Xml.Linq; + +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// The XML documentation of the public surface of Abstractions, Fluent, dependency injection and Hosting is +/// complete and states the exceptions of the failure contract (Abstractions README, "Failure, exception and +/// cancellation contract" and "Public API charter"). +/// +/// +/// +/// Each assembly is read with its generated XML documentation file, from the test output (the files the +/// packages contain); the public and protected members come from reflection and are matched by documentation +/// id. No Client code runs. +/// +/// +/// Structure. Every public type and member has its own summary (never inheritdoc), every +/// parameter a param, every type parameter a typeparam, every method, operator and delegate that +/// returns a value a returns, and every exception a condition. A property is described by its +/// summary. +/// +/// +/// Operation forms. A Try form (a Try method that returns with an +/// out CheatEngineFailure failure) and its Detailed twin return their failures, so they document +/// only what they can still throw: and +/// , never or +/// . The throwing twin with the same inputs documents the +/// four exceptions maps a failure to, the +/// cancellation only when it takes a token. The implementable callbacks of the charter keep the Try +/// shape but are implemented by the application, so the Client documents nothing they throw. +/// +/// +/// Arguments. Every form of one operation documents the same argument exceptions. An operation form +/// documents an (or or +/// ) that names each enum parameter and each Client input value +/// (*Request, *Definition, *Update, *Search, *Registration, *Script) +/// with paramref, since an undefined value or a request throws; every public +/// method and constructor does so for each reference-type parameter that is not nullable. Members that the +/// application implements or overrides are called by the Client and document no argument exception. +/// +/// +/// Coverage. Each rule asserts a floor on what it applied to (the members for the structure rules, the +/// classified operation forms, the validated parameters), and known members pin the classification, so a rule +/// that stops recognizing what it checks fails instead of passing on nothing. +/// +/// +public sealed class PublicApiDocumentationTests +{ + private const string TryPrefix = "Try"; + private const string DetailedSuffix = "Detailed"; + + /// A floor below the 158 operation forms (78 Try, 76 throwing, 4 Detailed) of the 1.0 surface. + private const int OperationFormFloor = 150; + + /// A floor below the 149 parameters the argument rule validates on the 1.0 surface. + private const int ValidatedParameterFloor = 130; + + private const string ActivationExpired = "T:CheatEngine.Client.Results.CheatEngineActivationExpiredException"; + private const string InvalidState = "T:CheatEngine.Client.Results.CheatEngineInvalidStateException"; + private const string OperationFailed = "T:CheatEngine.Client.Results.CheatEngineOperationException"; + private const string OperationCanceled = "T:CheatEngine.Client.Results.CheatEngineOperationCanceledException"; + + private static readonly string[] DocumentedAssemblies = + [ + "CheatEngine.Client.Abstractions", + "CheatEngine.Client.Fluent", + "CheatEngine.Client.Extensions.DependencyInjection", + "CheatEngine.Client.Hosting" + ]; + + private static readonly string[] ArgumentExceptions = + ["T:System.ArgumentException", "T:System.ArgumentNullException", "T:System.ArgumentOutOfRangeException"]; + + /// The suffixes of the charter's validated input values ("Value types and outcomes"). + private static readonly string[] InputSuffixes = + ["Request", "Definition", "Update", "Search", "Registration", "Script"]; + + /// + /// The interfaces the application implements and the Client calls (charter, "Call-only and Implementable + /// interfaces"), by full name. + /// + private static readonly HashSet ImplementableInterfaces = new(StringComparer.Ordinal) + { + "CheatEngine.Client.Lua.ILuaModule", + "CheatEngine.Client.Lua.ILuaOperation`1", + "CheatEngine.Client.Lua.ILuaResultMapper`2", + "CheatEngine.Client.Memory.IMemoryCodec`1", + "CheatEngine.Client.Modules.ICheatEngineClientModule" + }; + + /// + /// The operation forms that need no activation, by declaring type and name: they never throw the lifecycle + /// exceptions, so their documentation must not list them (charter: IProcessClient.TryGetLocalProcesses + /// needs no activation). + /// + private static readonly HashSet ActivationFreeOperations = new(StringComparer.Ordinal) + { + "CheatEngine.Client.Processes.IProcessClient.TryGetLocalProcesses", + "CheatEngine.Client.Processes.IProcessClient.GetLocalProcesses" + }; + + /// + /// Members whose documentation may break one rule, by documentation id, with the reason. Empty: an entry needs + /// a reason a reviewer accepts, and removes it + /// once the documentation complies. + /// + private static readonly Dictionary Exemptions = new(StringComparer.Ordinal); + + private static readonly Lazy Members = new(static () => [.. ReadMembers()]); + + [Fact] + public void EveryPublicTypeAndMemberHasItsOwnSummary() + { + AssertNoOffenders(Check(static member => CheckSummary(member), static _ => 1), 800, + "public types and members", + "A public type or member has no summary of its own; document it instead of inheriting its documentation:"); + } + + [Fact] + public void EveryParameterTypeParameterAndReturnValueIsDocumented() + { + AssertNoOffenders(Check(static member => CheckSignature(member), static _ => 1), 800, + "public types and members", + "A public member leaves a parameter, a type parameter or its return value undocumented:"); + } + + [Fact] + public void EveryOperationFormDocumentsTheClientExceptionsItCanRaise() + { + AssertNoOffenders( + Check(static member => CheckOperationForm(member), + static member => FormOf(member) == OperationForm.None ? 0 : 1), OperationFormFloor, + "operation forms", + "An operation form does not document the Client exceptions of the failure contract:"); + } + + [Fact] + public void EveryValidatedArgumentDocumentsItsArgumentException() + { + AssertNoOffenders( + Check(static member => CheckArguments(member), static member => ValidatedParameters(member).Count()), + ValidatedParameterFloor, "validated parameters", + "A public member does not document the argument exception of a parameter it validates:"); + } + + [Fact] + public void EachOperationFormIsRecognizedOnTheSurface() + { + Dictionary counts = DocumentedMembers() + .GroupBy(static member => FormOf(member)) + .ToDictionary(static group => group.Key, static group => group.Count()); + int tryForms = counts.GetValueOrDefault(OperationForm.Try); + int throwingForms = counts.GetValueOrDefault(OperationForm.Throwing); + int detailedForms = counts.GetValueOrDefault(OperationForm.Detailed); + + Assert.True(tryForms > 70, $"Only {tryForms} Try forms were recognized."); + Assert.True(throwingForms > 70, $"Only {throwingForms} throwing forms were recognized."); + Assert.True(detailedForms >= 4, $"Only {detailedForms} Detailed forms were recognized."); + } + + [Fact] + public void KnownMembersAreClassifiedAsTheCharterNamesThem() + { + AssertForm(OperationForm.Try, typeof(IMemoryClient), nameof(IMemoryClient.TryReadBytes)); + AssertForm(OperationForm.Throwing, typeof(IMemoryClient), nameof(IMemoryClient.ReadBytes)); + AssertForm(OperationForm.Detailed, typeof(IMemoryClient), nameof(IMemoryClient.ReadBytesDetailed)); + AssertForm(OperationForm.Try, typeof(IProcessClient), nameof(IProcessClient.TryGetLocalProcesses)); + AssertForm(OperationForm.Throwing, typeof(IProcessClient), nameof(IProcessClient.GetLocalProcesses)); + AssertForm(OperationForm.Throwing, typeof(ICheatEngineDispatcher), nameof(ICheatEngineDispatcher.Invoke)); + AssertForm(OperationForm.Throwing, typeof(MemoryAddressBuilder), nameof(MemoryAddressBuilder.ReadString)); + AssertForm(OperationForm.None, typeof(IMemoryCodec<>), nameof(IMemoryCodec<>.TryRead)); + + AssertReason("a validated input value", typeof(IMemoryClient), nameof(IMemoryClient.ReadBytes), "request"); + AssertReason("an enum", typeof(MemoryAddressBuilder), nameof(MemoryAddressBuilder.ReadString), "encoding"); + AssertReason("a non-nullable reference", typeof(ICheatEngineDispatcher), nameof(ICheatEngineDispatcher.Invoke), + "callback"); + Assert.Empty(DocumentedMembers().Where(static member => + member.Member.DeclaringType == typeof(IMemoryCodec<>)).SelectMany(ValidatedParameters)); + } + + [Fact] + public void EveryActivationFreeOperationNamesAnOperationForm() + { + Assert.All(ActivationFreeOperations, operation => Assert.Contains(DocumentedMembers(), member => + member.Member is MethodInfo method && FormOf(member) != OperationForm.None && + $"{method.DeclaringType!.FullName}.{method.Name}" == operation)); + } + + [Fact] + public void EveryExemptionNamesAMemberThatStillBreaksARule() + { + Dictionary members = DocumentedMembers() + .ToDictionary(static member => member.Id, StringComparer.Ordinal); + Assert.All(Exemptions, exemption => + { + Assert.False(string.IsNullOrWhiteSpace(exemption.Value), $"{exemption.Key} has no reason."); + Assert.True(members.TryGetValue(exemption.Key, out DocumentedMember? member), + $"{exemption.Key} is not a documented public member."); + Assert.NotEmpty(Violations(member)); + }); + } + + private static IEnumerable Violations(DocumentedMember member) + { + return CheckSummary(member).Concat(CheckSignature(member)).Concat(CheckOperationForm(member)) + .Concat(CheckArguments(member)); + } + + /// Applies a rule to every documented member and counts what the rule applied to. + /// The offences of one member. + /// How many things of one member the rule checks (forms, parameters). + private static (int Applicable, List Offenders) Check( + Func> rule, Func applicability) + { + int applicable = 0; + List offenders = []; + foreach (DocumentedMember member in DocumentedMembers()) + { + applicable += applicability(member); + if (!Exemptions.ContainsKey(member.Id)) + { + offenders.AddRange(rule(member).Select(offence => $"{member.Id}: {offence}")); + } + } + + return (applicable, offenders); + } + + private static void AssertNoOffenders((int Applicable, List Offenders) result, int minimum, + string applicableTo, string title) + { + Assert.True(result.Applicable > minimum, + $"The rule applied to only {result.Applicable} {applicableTo}; it no longer recognizes what it checks."); + Assert.True(result.Offenders.Count == 0, + title + Environment.NewLine + string.Join(Environment.NewLine, result.Offenders)); + } + + private static void AssertForm(OperationForm expected, Type type, string name) + { + OperationForm[] forms = [.. MethodsNamed(type, name).Select(FormOf)]; + Assert.NotEmpty(forms); + Assert.All(forms, form => Assert.Equal(expected, form)); + } + + private static void AssertReason(string expected, Type type, string name, string parameter) + { + string[] reasons = + [ + .. MethodsNamed(type, name).SelectMany(ValidatedParameters) + .Where(validated => validated.Parameter.Name == parameter) + .Select(static validated => validated.Reason) + ]; + Assert.NotEmpty(reasons); + Assert.All(reasons, reason => Assert.Equal(expected, reason)); + } + + private static IEnumerable MethodsNamed(Type type, string name) + { + return DocumentedMembers().Where(member => + member.Member is MethodInfo method && method.DeclaringType == type && method.Name == name); + } + + private static OperationForm FormOf(DocumentedMember member) + { + return member.Member is MethodInfo method ? Classify(method) : OperationForm.None; + } + + private static IEnumerable CheckSummary(DocumentedMember member) + { + if (member.Documentation is not { } documentation) + { + yield return "no documentation"; + yield break; + } + + if (documentation.Descendants("inheritdoc").Any()) + { + yield return "inherits its documentation"; + } + + if (!HasText(documentation.Element("summary"))) + { + yield return "no summary"; + } + } + + private static IEnumerable CheckSignature(DocumentedMember member) + { + if (member.Documentation is not { } documentation) + { + yield break; + } + + foreach (string parameter in member.Parameters) + { + if (!HasText(Named(documentation, "param", parameter))) + { + yield return $"no param '{parameter}'"; + } + } + + foreach (string typeParameter in member.TypeParameters) + { + if (!HasText(Named(documentation, "typeparam", typeParameter))) + { + yield return $"no typeparam '{typeParameter}'"; + } + } + + if (member.HasReturnValue && !HasText(documentation.Element("returns"))) + { + yield return "no returns"; + } + + foreach (XElement exception in documentation.Elements("exception")) + { + if (!HasText(exception)) + { + yield return $"exception {(string?) exception.Attribute("cref")} states no condition"; + } + } + } + + private static IEnumerable CheckOperationForm(DocumentedMember member) + { + if (member.Member is not MethodInfo method || member.Documentation is not { } documentation) + { + yield break; + } + + OperationForm form = Classify(method); + if (form == OperationForm.None) + { + yield break; + } + + HashSet documented = ExceptionCrefs(documentation); + string operation = $"{method.DeclaringType!.FullName}.{method.Name}"; + bool needsActivation = !ActivationFreeOperations.Contains(operation); + foreach (string lifecycle in (string[]) [ActivationExpired, InvalidState]) + { + if (needsActivation && !documented.Contains(lifecycle)) + { + yield return $"{form} form does not document {lifecycle[2..]}"; + } + else if (!needsActivation && documented.Contains(lifecycle)) + { + yield return $"{form} form needs no activation but documents {lifecycle[2..]}"; + } + } + + bool takesToken = method.GetParameters().Any(static p => p.ParameterType == typeof(CancellationToken)); + if (form == OperationForm.Throwing) + { + if (!documented.Contains(OperationFailed)) + { + yield return $"throwing form does not document {OperationFailed[2..]}"; + } + + if (takesToken && !documented.Contains(OperationCanceled)) + { + yield return $"throwing form takes a token but does not document {OperationCanceled[2..]}"; + } + } + else + { + foreach (string returned in (string[]) [OperationFailed, OperationCanceled]) + { + if (documented.Contains(returned)) + { + yield return $"{form} form returns its failures but documents {returned[2..]}"; + } + } + } + + MethodInfo? tryForm = form == OperationForm.Try ? method : FindTryForm(method, form); + if (tryForm is not null && tryForm != method && + DocumentationOf(tryForm) is { } tryDocumentation && + !ArgumentCrefs(tryDocumentation).SetEquals(ArgumentCrefs(documentation))) + { + yield return $"documents other argument exceptions than {tryForm.Name}"; + } + } + + private static IEnumerable CheckArguments(DocumentedMember member) + { + if (member.Documentation is not { } documentation) + { + return []; + } + + return ValidatedParameters(member) + .Where(validated => !DocumentsArgument(documentation, validated.Parameter.Name!)) + .Select(static validated => + $"'{validated.Parameter.Name}' is {validated.Reason} without an argument exception that names it"); + } + + /// The parameters the argument rule applies to, each with the reason it is validated. + private static IEnumerable<(ParameterInfo Parameter, string Reason)> ValidatedParameters(DocumentedMember member) + { + if (member.Member is not MethodBase method || IsImplementedByTheApplication(method)) + { + yield break; + } + + bool isOperation = method is MethodInfo info && Classify(info) != OperationForm.None; + NullabilityInfoContext nullability = new(); + foreach (ParameterInfo parameter in method.GetParameters()) + { + Type type = parameter.ParameterType; + if (parameter.IsOut || type.IsByRef) + { + continue; + } + + string? reason = null; + if (!type.IsValueType && !type.IsGenericParameter && + nullability.Create(parameter).ReadState == NullabilityState.NotNull) + { + reason = "a non-nullable reference"; + } + else if (isOperation && type.IsEnum) + { + reason = "an enum"; + } + else if (isOperation && IsClientInput(type)) + { + reason = "a validated input value"; + } + + if (reason is not null) + { + yield return (parameter, reason); + } + } + } + + private static bool DocumentsArgument(XElement documentation, string parameter) + { + return documentation.Elements("exception") + .Where(static exception => ArgumentExceptions.Contains((string?) exception.Attribute("cref"))) + .Any(exception => exception.Descendants("paramref") + .Any(reference => (string?) reference.Attribute("name") == parameter)); + } + + private static bool IsClientInput(Type type) + { + Type definition = type.IsGenericType ? type.GetGenericTypeDefinition() : type; + string name = definition.Name.Split('`')[0]; + return definition.IsValueType && + DocumentedAssemblies.Contains(definition.Assembly.GetName().Name, StringComparer.Ordinal) && + InputSuffixes.Any(suffix => name.EndsWith(suffix, StringComparison.Ordinal)); + } + + private static bool IsImplementedByTheApplication(MethodBase method) + { + Type type = method.DeclaringType!; + string name = type.IsGenericType ? type.GetGenericTypeDefinition().FullName! : type.FullName!; + return ImplementableInterfaces.Contains(name) || + (method is MethodInfo { IsAbstract: true } or MethodInfo { IsVirtual: true, IsFinal: false } && + !type.IsInterface && !type.IsSealed); + } + + private static OperationForm Classify(MethodInfo method) + { + Type type = method.DeclaringType!; + string typeName = type.IsGenericType ? type.GetGenericTypeDefinition().FullName! : type.FullName!; + if (ImplementableInterfaces.Contains(typeName) || method.IsSpecialName) + { + return OperationForm.None; + } + + if (IsTryForm(method)) + { + return OperationForm.Try; + } + + if (method.Name.EndsWith(DetailedSuffix, StringComparison.Ordinal) && + FindTryForm(method, OperationForm.Detailed) is not null) + { + return OperationForm.Detailed; + } + + return FindTryForm(method, OperationForm.Throwing) is not null ? OperationForm.Throwing : OperationForm.None; + } + + private static bool IsTryForm(MethodInfo method) + { + return method.Name.StartsWith(TryPrefix, StringComparison.Ordinal) && method.ReturnType == typeof(bool) && + method.GetParameters().Any(static parameter => + parameter.IsOut && parameter.ParameterType.GetElementType() == typeof(CheatEngineFailure)); + } + + /// Finds the Try form with the same inputs as a throwing or Detailed form. + private static MethodInfo? FindTryForm(MethodInfo method, OperationForm form) + { + string name = form == OperationForm.Detailed ? method.Name[..^DetailedSuffix.Length] : method.Name; + if (name.StartsWith(TryPrefix, StringComparison.Ordinal)) + { + return null; + } + + string[] inputs = Inputs(method); + return method.DeclaringType! + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .FirstOrDefault(candidate => candidate.Name == TryPrefix + name && IsTryForm(candidate) && + candidate.GetGenericArguments().Length == + method.GetGenericArguments().Length && + Inputs(candidate).SequenceEqual(inputs, StringComparer.Ordinal)); + + static string[] Inputs(MethodInfo method) + { + return + [ + .. method.GetParameters() + .Where(static parameter => !parameter.IsOut && parameter.ParameterType != typeof(CancellationToken)) + .Select(static parameter => parameter.ParameterType.IsGenericMethodParameter + ? $"!!{parameter.ParameterType.GenericParameterPosition}" + : parameter.ParameterType.ToString()) + ]; + } + } + + private static XElement? DocumentationOf(MethodInfo method) + { + return Members.Value.FirstOrDefault(member => member.Member == method)?.Documentation; + } + + private static HashSet ExceptionCrefs(XElement documentation) + { + return + [ + .. documentation.Elements("exception").Select(static exception => (string?) exception.Attribute("cref")) + .OfType() + ]; + } + + private static HashSet ArgumentCrefs(XElement documentation) + { + HashSet crefs = ExceptionCrefs(documentation); + crefs.IntersectWith(ArgumentExceptions); + return crefs; + } + + private static XElement? Named(XElement documentation, string tag, string name) + { + return documentation.Elements(tag).FirstOrDefault(element => (string?) element.Attribute("name") == name); + } + + private static bool HasText(XElement? element) + { + return element is not null && (element.HasElements || !string.IsNullOrWhiteSpace(element.Value)); + } + + private static DocumentedMember[] DocumentedMembers() + { + return Members.Value; + } + + private static IEnumerable ReadMembers() + { + foreach (string name in DocumentedAssemblies) + { + System.Reflection.Assembly assembly = ClientAssemblyCatalog.Load(name); + Dictionary documentation = XDocument + .Load(Path.ChangeExtension(assembly.Location, ".xml")) + .Descendants("member") + .ToDictionary(static member => (string) member.Attribute("name")!, StringComparer.Ordinal); + foreach (Type type in assembly.GetExportedTypes()) + { + yield return DescribeType(type, documentation); + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly)) + { + if (member is not Type && IsVisible(member) && !IsGenerated(member)) + { + yield return DescribeMember(member, documentation); + } + } + } + } + } + + private static DocumentedMember DescribeType(Type type, Dictionary documentation) + { + string id = "T:" + DocumentationIds.TypeName(type); + int inherited = type.DeclaringType?.GetGenericArguments().Length ?? 0; + string[] typeParameters = [.. type.GetGenericArguments().Skip(inherited).Select(static t => t.Name)]; + string[] parameters = []; + bool returns = false; + if (type.IsSubclassOf(typeof(Delegate))) + { + MethodInfo invoke = type.GetMethod("Invoke")!; + parameters = [.. invoke.GetParameters().Select(static parameter => parameter.Name!)]; + returns = invoke.ReturnType != typeof(void); + } + + return new DocumentedMember(id, type, documentation.GetValueOrDefault(id), parameters, typeParameters, returns); + } + + private static DocumentedMember DescribeMember(MemberInfo member, Dictionary documentation) + { + string id = DocumentationIds.Of(member); + string[] parameters = member switch + { + MethodBase method => [.. method.GetParameters().Select(static parameter => parameter.Name!)], + PropertyInfo property => [.. property.GetIndexParameters().Select(static parameter => parameter.Name!)], + _ => [] + }; + string[] typeParameters = member is MethodInfo { IsGenericMethodDefinition: true } generic + ? [.. generic.GetGenericArguments().Select(static t => t.Name)] + : []; + bool returns = member is MethodInfo { ReturnType: var returnType } && returnType != typeof(void); + return new DocumentedMember(id, member, documentation.GetValueOrDefault(id), parameters, typeParameters, + returns); + } + + private static bool IsVisible(MemberInfo member) + { + return member switch + { + MethodBase method => (method is ConstructorInfo || !method.IsSpecialName || + method.Name.StartsWith("op_", StringComparison.Ordinal)) && IsVisible(method), + PropertyInfo property => property.GetAccessors(true).Any(IsVisible), + EventInfo @event => IsVisible(@event.AddMethod!), + FieldInfo field => !field.IsSpecialName && (field.IsPublic || field.IsFamily || field.IsFamilyOrAssembly), + _ => false + }; + } + + private static bool IsVisible(MethodBase method) + { + return method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly; + } + + /// Members the compiler synthesizes (record members), which carry no documentation of their own. + private static bool IsGenerated(MemberInfo member) + { + return member.IsDefined(typeof(CompilerGeneratedAttribute), false) || + (member is PropertyInfo { Name: "EqualityContract" } property && + property.GetAccessors(true).All(static accessor => + accessor.IsDefined(typeof(CompilerGeneratedAttribute), false))); + } + + private enum OperationForm + { + None = 0, + Try = 1, + Throwing = 2, + Detailed = 3 + } + + private sealed record DocumentedMember( + string Id, + MemberInfo Member, + XElement? Documentation, + string[] Parameters, + string[] TypeParameters, + bool HasReturnValue); + + /// + /// Computes the documentation id the compiler writes for a type or member (ECMA-334, annex D.4.2). + /// + private static class DocumentationIds + { + internal static string Of(MemberInfo member) + { + string type = TypeName(member.DeclaringType!); + return member switch + { + ConstructorInfo constructor => + $"M:{type}.{(constructor.IsStatic ? "#cctor" : "#ctor")}{Parameters(constructor.GetParameters())}", + MethodInfo method => $"M:{type}.{method.Name.Replace('.', '#')}" + + (method.IsGenericMethodDefinition + ? "``" + method.GetGenericArguments().Length + : string.Empty) + + Parameters(method.GetParameters()) + + (method.Name is "op_Implicit" or "op_Explicit" + ? "~" + ParameterType(method.ReturnType) + : string.Empty), + PropertyInfo property => $"P:{type}.{property.Name}{Parameters(property.GetIndexParameters())}", + FieldInfo field => $"F:{type}.{field.Name}", + EventInfo @event => $"E:{type}.{@event.Name}", + _ => throw new ArgumentOutOfRangeException(nameof(member), member, "Unsupported member.") + }; + } + + internal static string TypeName(Type type) + { + return type.FullName!.Replace('+', '.'); + } + + private static string Parameters(ParameterInfo[] parameters) + { + return parameters.Length == 0 + ? string.Empty + : "(" + string.Join(",", parameters.Select(static p => ParameterType(p.ParameterType))) + ")"; + } + + private static string ParameterType(Type type) + { + if (type.IsByRef) + { + return ParameterType(type.GetElementType()!) + "@"; + } + + if (type.IsPointer) + { + return ParameterType(type.GetElementType()!) + "*"; + } + + if (type.IsArray) + { + return ParameterType(type.GetElementType()!) + (type.IsSZArray + ? "[]" + : "[" + string.Join(",", Enumerable.Repeat("0:", type.GetArrayRank())) + "]"); + } + + if (type.IsGenericParameter) + { + return (type.DeclaringMethod is null ? "`" : "``") + type.GenericParameterPosition; + } + + return type.IsGenericType ? GenericTypeName(type) : TypeName(type); + } + + /// Writes a constructed type with its arguments in braces, level by level of nesting. + private static string GenericTypeName(Type type) + { + Type[] arguments = type.GetGenericArguments(); + List levels = []; + for (Type? level = type.GetGenericTypeDefinition(); level is not null; level = level.DeclaringType) + { + levels.Insert(0, level); + } + + StringBuilder text = new(levels[0].Namespace is { Length: > 0 } space ? space + "." : string.Empty); + int used = 0; + for (int index = 0; index < levels.Count; index++) + { + string name = levels[index].Name; + int tick = name.IndexOf('`', StringComparison.Ordinal); + int arity = tick < 0 ? 0 : int.Parse(name[(tick + 1)..], CultureInfo.InvariantCulture); + text.Append(index == 0 ? string.Empty : ".").Append(tick < 0 ? name : name[..tick]); + if (arity > 0) + { + text.Append('{') + .Append(string.Join(",", arguments.Skip(used).Take(arity).Select(ParameterType))) + .Append('}'); + used += arity; + } + } + + return text.ToString(); + } + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/PublicSurfaceInventoryTests.cs b/tests/CheatEngine.Client.Tests/Architecture/PublicSurfaceInventoryTests.cs new file mode 100644 index 0000000..68c0253 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/PublicSurfaceInventoryTests.cs @@ -0,0 +1,61 @@ +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// The public surface of 1.x keeps out of the namespaces that the Client removed or never shipped, and Core, the +/// CheatEngine.SDK-facing implementation, exports nothing. +/// +/// +/// A list of forbidden namespaces, not an exact pin of the surface: the PublicAPI files and their analyzers pin every +/// symbol, and a 1.x minor release may add types anywhere else. +/// +public sealed class PublicSurfaceInventoryTests +{ + /// The namespaces no shipped Client assembly exports a type in, nested namespaces included. + private static readonly string[] ForbiddenNamespaces = + [ + "CheatEngine.Client.Abstractions", + "CheatEngine.Client.Timers", + "CheatEngine.Client.Hotkeys", + "CheatEngine.Client.Debugger", + "CheatEngine.Client.Speed", + "CheatEngine.Client.Hashing", + "CheatEngine.Client.Dbvm", + "CheatEngine.Client.RemoteExecution", + "CheatEngine.Client.Events" + ]; + + [Fact] + public void NoShippedAssemblyExportsATypeInAForbiddenNamespace() + { + string[] offenders = + [ + .. ClientAssemblyCatalog.LoadAll() + .SelectMany(static assembly => assembly.GetExportedTypes()) + .Where(static type => type.Namespace is { } ns && ForbiddenNamespaces.Any(forbidden => + ns == forbidden || ns.StartsWith(forbidden + ".", StringComparison.Ordinal))) + .Select(static type => $"{type.Assembly.GetName().Name}: {type.FullName}") + ]; + + Assert.True(offenders.Length == 0, + "These types are exported in a namespace the 1.x surface does not have: " + string.Join(", ", offenders)); + } + + [Fact] + public void CoreExportsNoType() + { + Assert.Empty(ClientAssemblyCatalog.Load("CheatEngine.Client.Core").GetExportedTypes()); + } + + [Fact] + public void TheInventoryInspectsEveryShippedAssembly() + { + Assert.Equal( + [ + "CheatEngine.Client", "CheatEngine.Client.Abstractions", "CheatEngine.Client.Core", + "CheatEngine.Client.Extensions.DependencyInjection", "CheatEngine.Client.Fluent", + "CheatEngine.Client.Hosting" + ], + ClientAssemblyCatalog.LoadAll().Select(static assembly => assembly.GetName().Name!) + .Order(StringComparer.Ordinal)); + } +} diff --git a/tests/CheatEngine.Client.Tests/Architecture/SdkExperimentalApiRatchetTests.cs b/tests/CheatEngine.Client.Tests/Architecture/SdkExperimentalApiRatchetTests.cs new file mode 100644 index 0000000..6e23f0c --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Architecture/SdkExperimentalApiRatchetTests.cs @@ -0,0 +1,408 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +using CheatEngine.Client.Tests.Infrastructure; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.Architecture; + +/// +/// C0 ratchet: the Client uses no [Experimental] member of the consumed CheatEngine.SDK and never suppresses +/// an SDK experimental diagnostic (CESDK5xxx). +/// +/// +/// +/// The metadata scan reads every [Experimental] type and member of the referenced SDK assemblies and +/// fails when a shipped Client assembly references one. proves the scan is not +/// vacuous: each floor member must be found experimental in the consumed package, with its diagnostic id. +/// +/// +/// The text scan covers suppression mechanisms only: #pragma warning disable, SuppressMessage +/// attributes, MSBuild NoWarn and WarningsNotAsErrors, .editorconfig and +/// .globalconfig severities, and response-file -nowarn. Comments and documentation may cite the +/// identifiers. +/// +/// +public sealed partial class SdkExperimentalApiRatchetTests +{ + private const string ExperimentalAttributeType = "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"; + + private const int RegexTimeoutMilliseconds = 1000; + + private const string AobScanner = "CheatEngine.SDK.Engine.Scanning.Aob.AobScanner"; + + private const string AobTypes = "CheatEngine.SDK.Engine.Scanning.Aob."; + + private const string MemoryScanSession = "CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession"; + + /// The bounded AOB scan without a deadline is stable API; the scan must tell the two overloads apart. + private const string StableBoundedScan = + AobScanner + "::TryScanWithinBounds(string," + AobTypes + "AobScanBounds," + AobTypes + "AobScanOptions," + + "System.Span`1,System.Threading.CancellationToken)->" + AobTypes + + "AobBoundedScanResult"; + + /// + /// The experimental members of CheatEngine.SDK 2.0.0 the scan must find, with the diagnostic id each one raises. + /// + private static readonly (string Member, string DiagnosticId)[] ExperimentalFloor = + [ + ("CheatEngine.SDK.Lua.Runtime.LuaRuntime::AdmitWorkerThreads()->void", "CESDK5001"), + (MemoryScanSession + + "::TryWaitForCompletion(System.TimeSpan)->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus", + "CESDK5010"), + (MemoryScanSession + + "::TryTerminateScan(System.TimeSpan)->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus", + "CESDK5010"), + (AobScanner + "::TryScanWithinBounds(string," + AobTypes + "AobScanBounds," + AobTypes + "AobScanOptions," + + "System.TimeSpan,System.Span`1,System.Threading.CancellationToken)->" + + AobTypes + "AobBoundedScanResult", "CESDK5010"), + (AobScanner + "::TryFindFirstFoundWithinBounds(string," + AobTypes + "AobScanBounds," + AobTypes + + "AobScanOptions,System.Threading.CancellationToken)->" + AobTypes + "AobFirstFoundResult", "CESDK5011") + ]; + + private static readonly HashSet SkippedDirectories = new(StringComparer.OrdinalIgnoreCase) + { + "artifacts", "bin", "obj", ".git", ".vs", ".idea", "TestResults", "node_modules" + }; + + private static readonly HashSet MsBuildExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".csproj", ".props", ".targets" + }; + + private static readonly string[] MsBuildSuppressionElements = ["NoWarn", "WarningsNotAsErrors"]; + + private static readonly Lazy Surface = new(ReadExperimentalSurface, + LazyThreadSafetyMode.ExecutionAndPublication); + + [Fact] + public void TheConsumedSdkMarksEveryFloorMemberExperimental() + { + ExperimentalSurface surface = Surface.Value; + string inventory = string.Join(Environment.NewLine, + surface.Members.OrderBy(static entry => entry.Key, StringComparer.Ordinal) + .Select(static entry => $"{entry.Value} {entry.Key}")); + + foreach ((string member, string diagnosticId) in ExperimentalFloor) + { + Assert.True(surface.Members.TryGetValue(member, out string? found), + $"{member} is not [Experimental] in the consumed CheatEngine.SDK. Found:{Environment.NewLine}{inventory}"); + Assert.Equal(diagnosticId, found); + } + + Assert.DoesNotContain(StableBoundedScan, surface.Members.Keys); + } + + [Fact] + public void ClientAssembliesReferenceNoExperimentalSdkApi() + { + ExperimentalSurface surface = Surface.Value; + List violations = []; + foreach (string assembly in ClientAssemblyCatalog.Names) + { + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach (MemberReferenceHandle handle in reader.MemberReferences) + { + string member = MetadataSurface.DescribeMember(reader, handle, + out MetadataSurface.TypeIdentity declaringType); + if (declaringType.IsSdk && surface.TryGetDiagnosticId(member, declaringType, out string? id)) + { + violations.Add($"{assembly} references {member} [Experimental(\"{id}\")]."); + } + } + + foreach (TypeReferenceHandle handle in reader.TypeReferences) + { + MetadataSurface.TypeIdentity type = MetadataSurface.ResolveType(reader, handle); + if (type.IsSdk && surface.TryGetTypeDiagnosticId(type, out string? id)) + { + violations.Add($"{assembly} references {type.FullName} [Experimental(\"{id}\")]."); + } + } + }); + } + + Assert.True(violations.Count == 0, + "The Client uses no experimental CheatEngine.SDK API (decision 3 of the 1.0 plan):" + Environment.NewLine + + string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void NoSourceOrBuildFileSuppressesAnSdkExperimentalDiagnostic() + { + List offenders = []; + int scanned = 0; + foreach (string file in EnumerateScannedFiles()) + { + scanned++; + string relative = Path.GetRelativePath(RepositoryLayout.Root, file).Replace('\\', '/'); + offenders.AddRange(FindSuppressions(relative, File.ReadAllText(file)) + .Select(suppression => $"{relative}: {suppression}")); + } + + Assert.True(scanned > 100, $"Only {scanned} files were scanned; the repository walk is broken."); + Assert.True(offenders.Count == 0, + "An SDK experimental diagnostic is suppressed; the Client never opts into experimental SDK API:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Theory] + [InlineData("Probe.cs", "#pragma warning disable CESDK5010")] + [InlineData("Probe.cs", "\t#pragma warning disable CS0618, CESDK5001 // deliberate")] + [InlineData("Probe.cs", "[SuppressMessage(\"Usage\", \"CESDK5011:Experimental API\", Justification = \"x\")]")] + [InlineData("Probe.cs", + "[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\",\n\t\"CESDK5010\", Justification = \"x\")]")] + [InlineData("Probe.props", "$(NoWarn);CESDK5010")] + [InlineData("Probe.csproj", + "CESDK5001")] + [InlineData("Probe.targets", "")] + [InlineData(".editorconfig", "[*.cs]\ndotnet_diagnostic.CESDK5010.severity = none")] + [InlineData("Probe.globalconfig", "is_global = true\ndotnet_diagnostic.CESDK5001.severity = warning")] + [InlineData("Directory.Build.rsp", "-nowarn:CESDK5011")] + public void TheSuppressionScanRecognizesEverySuppressionForm(string path, string text) + { + Assert.NotEmpty(FindSuppressions(path, text)); + } + + [Theory] + [InlineData("Probe.cs", "// CESDK5010 marks the deadline overload of TryScanWithinBounds experimental.")] + [InlineData("Probe.cs", "/// The SDK gates AdmitWorkerThreads behind [Experimental(\"CESDK5001\")].")] + [InlineData("Probe.cs", "#pragma warning disable CS0618 // CESDK5010 stays an error")] + [InlineData("Probe.props", "CS1591")] + [InlineData(".editorconfig", "# dotnet_diagnostic.CESDK5001.severity = none is forbidden")] + public void CommentsMayCiteSdkExperimentalDiagnostics(string path, string text) + { + Assert.Empty(FindSuppressions(path, text)); + } + + /// Returns every suppression of an SDK experimental diagnostic in one file. + private static List FindSuppressions(string path, string text) + { + string name = Path.GetFileName(path); + string extension = Path.GetExtension(path); + List found = []; + if (extension.Equals(".cs", StringComparison.OrdinalIgnoreCase)) + { + found.AddRange(PragmaDisable().Matches(text) + .Where(static match => ExperimentalId().IsMatch(match.Groups["ids"].Value.Split("//", 2)[0])) + .Select(static match => match.Value.Trim())); + found.AddRange(SuppressMessageAttribute().Matches(text) + .Where(static match => ExperimentalId().IsMatch(match.Groups["arguments"].Value)) + .Select(static match => match.Value.Trim())); + } + else if (MsBuildExtensions.Contains(extension)) + { + XDocument document = XDocument.Parse(text); + found.AddRange(document.Descendants() + .Where(static element => MsBuildSuppressionElements.Contains(element.Name.LocalName) && + ExperimentalId().IsMatch(element.Value)) + .Select(static element => $"<{element.Name.LocalName}>{element.Value}")); + found.AddRange(document.Descendants().Attributes() + .Where(static attribute => MsBuildSuppressionElements.Contains(attribute.Name.LocalName) && + ExperimentalId().IsMatch(attribute.Value)) + .Select(static attribute => attribute.ToString())); + } + else if (name.Equals(".editorconfig", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".globalconfig", StringComparison.OrdinalIgnoreCase)) + { + found.AddRange(SeverityConfiguration().Matches(text).Select(static match => match.Value.Trim())); + } + else if (extension.Equals(".rsp", StringComparison.OrdinalIgnoreCase)) + { + found.AddRange(ResponseFileNoWarn().Matches(text).Select(static match => match.Value.Trim())); + } + + return found; + } + + private static IEnumerable EnumerateScannedFiles() + { + Stack directories = new([RepositoryLayout.Root]); + while (directories.TryPop(out string? directory)) + { + foreach (string child in Directory.EnumerateDirectories(directory)) + { + if (!SkippedDirectories.Contains(Path.GetFileName(child))) + { + directories.Push(child); + } + } + + foreach (string file in Directory.EnumerateFiles(directory)) + { + string extension = Path.GetExtension(file); + if (extension.Equals(".cs", StringComparison.OrdinalIgnoreCase) || MsBuildExtensions.Contains(extension) || + extension.Equals(".globalconfig", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".rsp", StringComparison.OrdinalIgnoreCase) || + Path.GetFileName(file).Equals(".editorconfig", StringComparison.OrdinalIgnoreCase)) + { + yield return file; + } + } + } + } + + private static ExperimentalSurface ReadExperimentalSurface() + { + ExperimentalSurface surface = new(); + foreach (ReflectionAssembly assembly in ConsumedSdkAssemblies.All) + { + using FileStream stream = File.OpenRead(assembly.Location); + using PEReader peReader = new(stream); + MetadataReader reader = peReader.GetMetadataReader(); + if (TryGetDiagnosticId(reader, reader.GetAssemblyDefinition().GetCustomAttributes(), out string? assemblyId)) + { + surface.Assemblies[reader.GetString(reader.GetAssemblyDefinition().Name)] = assemblyId; + } + + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + if (TryGetDiagnosticId(reader, reader.GetTypeDefinition(handle).GetCustomAttributes(), out string? id)) + { + surface.Types[MetadataSurface.ResolveTypeDefinition(reader, handle).FullName] = id; + } + } + + foreach (MethodDefinitionHandle handle in reader.MethodDefinitions) + { + if (TryGetDiagnosticId(reader, reader.GetMethodDefinition(handle).GetCustomAttributes(), out string? id)) + { + surface.Members[MetadataSurface.DescribeMethodDefinition(reader, handle)] = id; + } + } + + foreach (PropertyDefinitionHandle handle in reader.PropertyDefinitions) + { + PropertyDefinition property = reader.GetPropertyDefinition(handle); + if (TryGetDiagnosticId(reader, property.GetCustomAttributes(), out string? id)) + { + PropertyAccessors accessors = property.GetAccessors(); + AddAccessors(reader, surface, id, accessors.Getter, accessors.Setter); + } + } + + foreach (EventDefinitionHandle handle in reader.EventDefinitions) + { + EventDefinition definition = reader.GetEventDefinition(handle); + if (TryGetDiagnosticId(reader, definition.GetCustomAttributes(), out string? id)) + { + EventAccessors accessors = definition.GetAccessors(); + AddAccessors(reader, surface, id, accessors.Adder, accessors.Remover, accessors.Raiser); + } + } + + foreach (FieldDefinitionHandle handle in reader.FieldDefinitions) + { + if (TryGetDiagnosticId(reader, reader.GetFieldDefinition(handle).GetCustomAttributes(), out string? id)) + { + surface.Members[MetadataSurface.DescribeFieldDefinition(reader, handle)] = id; + } + } + } + + return surface; + } + + private static void AddAccessors(MetadataReader reader, ExperimentalSurface surface, string id, + params MethodDefinitionHandle[] accessors) + { + foreach (MethodDefinitionHandle accessor in accessors.Where(static accessor => !accessor.IsNil)) + { + surface.Members[MetadataSurface.DescribeMethodDefinition(reader, accessor)] = id; + } + } + + private static bool TryGetDiagnosticId(MetadataReader reader, CustomAttributeHandleCollection attributes, + [NotNullWhen(true)] out string? diagnosticId) + { + foreach (CustomAttributeHandle handle in attributes) + { + CustomAttribute attribute = reader.GetCustomAttribute(handle); + if (MetadataSurface.GetAttributeTypeName(reader, attribute) != ExperimentalAttributeType) + { + continue; + } + + // ECMA-335 II.23.3: the prolog 0x0001, then the diagnostic id as the only fixed argument (a SerString). + BlobReader value = reader.GetBlobReader(attribute.Value); + _ = value.ReadUInt16(); + diagnosticId = value.ReadSerializedString() ?? string.Empty; + return true; + } + + diagnosticId = null; + return false; + } + + [GeneratedRegex(@"CESDK5\d{3}", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ExperimentalId(); + + [GeneratedRegex(@"^[ \t]*#[ \t]*pragma[ \t]+warning[ \t]+disable\b(?[^\r\n]*)", + RegexOptions.CultureInvariant | RegexOptions.Multiline, RegexTimeoutMilliseconds)] + private static partial Regex PragmaDisable(); + + [GeneratedRegex( + @"^[ \t]*\[[ \t]*(?:(?:assembly|module)[ \t]*:[ \t]*)?(?:global::)?(?:System\.Diagnostics\.CodeAnalysis\.)?(?:Unconditional)?SuppressMessage(?:Attribute)?[ \t]*\((?[^)]*)\)", + RegexOptions.CultureInvariant | RegexOptions.Multiline, RegexTimeoutMilliseconds)] + private static partial Regex SuppressMessageAttribute(); + + [GeneratedRegex(@"^[ \t]*dotnet_diagnostic\.CESDK5\d{3}\.severity[ \t]*=[^\r\n]*", + RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.IgnoreCase, RegexTimeoutMilliseconds)] + private static partial Regex SeverityConfiguration(); + + [GeneratedRegex(@"(?:^|\s)[-/]nowarn:[^\r\n]*CESDK5\d{3}", + RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.IgnoreCase, RegexTimeoutMilliseconds)] + private static partial Regex ResponseFileNoWarn(); + + /// The [Experimental] assemblies, types and members of the consumed SDK, with their diagnostic ids. + private sealed class ExperimentalSurface + { + internal Dictionary Assemblies + { + get; + } = new(StringComparer.Ordinal); + + internal Dictionary Types + { + get; + } = new(StringComparer.Ordinal); + + internal Dictionary Members + { + get; + } = new(StringComparer.Ordinal); + + /// Whether a referenced member, its declaring type, an enclosing type or its assembly is experimental. + internal bool TryGetDiagnosticId(string member, MetadataSurface.TypeIdentity declaringType, + [NotNullWhen(true)] out string? diagnosticId) + { + return Members.TryGetValue(member, out diagnosticId) || TryGetTypeDiagnosticId(declaringType, out diagnosticId); + } + + /// Whether a referenced type, an enclosing type or its assembly is experimental. + internal bool TryGetTypeDiagnosticId(MetadataSurface.TypeIdentity type, [NotNullWhen(true)] out string? diagnosticId) + { + if (Assemblies.TryGetValue(type.Assembly, out diagnosticId)) + { + return true; + } + + string[] nesting = type.FullName.Split('+'); + for (int depth = 1; depth <= nesting.Length; depth++) + { + if (Types.TryGetValue(string.Join('+', nesting[..depth]), out diagnosticId)) + { + return true; + } + } + + diagnosticId = null; + return false; + } + } +} diff --git a/tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj b/tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj index 1247d7f..c2a9e43 100644 --- a/tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj +++ b/tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj @@ -2,10 +2,26 @@ true + + $(NoWarn);CECLIENT5001;CECLIENT5002 + + + + + + + + + diff --git a/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs b/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs index 35f4ba9..6ffa17b 100644 --- a/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs +++ b/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs @@ -12,8 +12,6 @@ using CheatEngine.Client.Tables; using CheatEngine.SDK.Engine.Values; -using MemoryFluent = CheatEngine.Client.Memory.Memory; - namespace CheatEngine.Client.Tests; public sealed class FacadeGraphSmokeTests @@ -22,8 +20,8 @@ public sealed class FacadeGraphSmokeTests public void MetaPackageReferenceProvidesTheFunctionalClientSurface() { Address address = 0x401000; - MemoryAddressBuilder memoryOperation = MemoryFluent.At(address); - AobPattern pattern = new("48 8B ?? 89"); + MemoryAddressBuilder memoryOperation = DispatchProxy.Create().At(address); + AobScanBuilder scanOperation = DispatchProxy.Create().Aob("48 8b ?? 89"); Dictionary entryPoints = new() { @@ -45,7 +43,7 @@ public void MetaPackageReferenceProvidesTheFunctionalClientSurface() }; Assert.Equal(address, memoryOperation.Address); - Assert.Equal("48 8B ?? 89", pattern.Value); + Assert.Equal("48 8B ?? 89", scanOperation.Pattern.Value); Assert.All(entryPoints, static entryPoint => Assert.Equal(entryPoint.Value, entryPoint.Key.Namespace)); } @@ -71,8 +69,9 @@ public void PublicClientContractsDoNotExposeSdkOwnershipOrLuaHandles() typeof(IMemoryReadContext), typeof(IMemoryWriteContext), typeof(MemoryAddressBuilder), + typeof(MemoryPointerChainBuilder), + typeof(MemoryPrimitiveBatchBuilder<>), typeof(CheatEngineMemoryFluentExtensions), - typeof(MemoryFluent), typeof(AobScanBuilder), typeof(AobFirstMatchBuilder), typeof(AobSingleMatchBuilder), @@ -99,13 +98,13 @@ private static IEnumerable GetPublicSignatureTypes(Type contract) } foreach (PropertyInfo property in contract.GetProperties(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.Static)) + BindingFlags.Static)) { yield return property.PropertyType; } foreach (MethodInfo method in contract.GetMethods(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.Static)) + BindingFlags.Static)) { yield return method.ReturnType; foreach (ParameterInfo parameter in method.GetParameters()) @@ -128,6 +127,15 @@ private static bool IsForbiddenSdkHandle(Type type) } return type.Name is "LuaState" or "LuaRef" or "CEObject" or "MemScan" or "FoundList" - || type.Name.StartsWith("Owned`", StringComparison.Ordinal); + || type.Name.StartsWith("Owned`", StringComparison.Ordinal); + } + + /// A service the smoke test binds a Fluent builder to without ever running a terminal. + public class UnusedServiceProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + throw new NotSupportedException("The facade smoke test must not run a Fluent terminal."); + } } } diff --git a/tests/CheatEngine.Client.Tests/Infrastructure/DotNetProcess.cs b/tests/CheatEngine.Client.Tests/Infrastructure/DotNetProcess.cs index 237f0d6..af97ea3 100644 --- a/tests/CheatEngine.Client.Tests/Infrastructure/DotNetProcess.cs +++ b/tests/CheatEngine.Client.Tests/Infrastructure/DotNetProcess.cs @@ -3,7 +3,10 @@ namespace CheatEngine.Client.Tests.Infrastructure; -/// Runs the pinned dotnet command without shell quoting or shared CLI state. +/// +/// Runs the pinned dotnet command, or another command-line tool such as pwsh, without shell quoting or +/// shared CLI state. +/// internal static class DotNetProcess { private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(10); @@ -13,14 +16,22 @@ internal static Task RunAsync(string workingDirectory, para return RunAsync(workingDirectory, new Dictionary(StringComparer.Ordinal), arguments); } - internal static async Task RunAsync(string workingDirectory, + internal static Task RunAsync(string workingDirectory, IReadOnlyDictionary environment, params string[] arguments) { + return RunToolAsync("dotnet", workingDirectory, environment, arguments); + } + + /// Runs , found on the PATH, with the same isolation and timeout. + internal static async Task RunToolAsync(string fileName, string workingDirectory, + IReadOnlyDictionary environment, params string[] arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); ArgumentNullException.ThrowIfNull(environment); ArgumentNullException.ThrowIfNull(arguments); - ProcessStartInfo startInfo = new("dotnet") + ProcessStartInfo startInfo = new(fileName) { WorkingDirectory = workingDirectory, RedirectStandardOutput = true, @@ -39,10 +50,13 @@ internal static async Task RunAsync(string workingDirectory startInfo.Environment[name] = value; } - using Process process = new() { StartInfo = startInfo }; + using Process process = new() + { + StartInfo = startInfo + }; if (!process.Start()) { - throw new InvalidOperationException("The dotnet process did not start."); + throw new InvalidOperationException($"The {fileName} process did not start."); } Task standardOutput = process.StandardOutput.ReadToEndAsync(); @@ -56,24 +70,25 @@ internal static async Task RunAsync(string workingDirectory { process.Kill(true); await process.WaitForExitAsync(); - throw new TimeoutException($"dotnet {string.Join(' ', arguments)} exceeded {Timeout}."); + throw new TimeoutException($"{fileName} {string.Join(' ', arguments)} exceeded {Timeout}."); } - return new DotNetProcessResult(arguments, process.ExitCode, await standardOutput, await standardError); + return new DotNetProcessResult(arguments, process.ExitCode, await standardOutput, await standardError, fileName); } } -/// Captures a completed dotnet invocation for assertion diagnostics. +/// Captures a completed dotnet (or other tool) invocation for assertion diagnostics. internal sealed record DotNetProcessResult( IReadOnlyList Arguments, int ExitCode, string StandardOutput, - string StandardError) + string StandardError, + string FileName = "dotnet") { public override string ToString() { - return $"dotnet {string.Join(' ', Arguments)} exited with {ExitCode}.{Environment.NewLine}" + - $"stdout:{Environment.NewLine}{StandardOutput}{Environment.NewLine}" + - $"stderr:{Environment.NewLine}{StandardError}"; + return $"{FileName} {string.Join(' ', Arguments)} exited with {ExitCode}.{Environment.NewLine}" + + $"stdout:{Environment.NewLine}{StandardOutput}{Environment.NewLine}" + + $"stderr:{Environment.NewLine}{StandardError}"; } } diff --git a/tests/CheatEngine.Client.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.Client.Tests/Infrastructure/RepositoryLayout.cs new file mode 100644 index 0000000..80ba3e9 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Infrastructure/RepositoryLayout.cs @@ -0,0 +1,35 @@ +namespace CheatEngine.Client.Tests.Infrastructure; + +/// Locates the repository that built the running test assembly. +internal static class RepositoryLayout +{ + private const string SolutionFileName = "CheatEngine.Client.slnx"; + + private static readonly Lazy LazyRoot = new(FindRoot, LazyThreadSafetyMode.ExecutionAndPublication); + + /// The directory that contains CheatEngine.Client.slnx. + internal static string Root => LazyRoot.Value; + + /// An absolute path below the repository root, from a forward-slash relative path. + internal static string Combine(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + return Path.GetFullPath(Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar))); + } + + private static string FindRoot() + { + for (DirectoryInfo? candidate = new(AppContext.BaseDirectory); + candidate is not null; + candidate = candidate.Parent) + { + if (File.Exists(Path.Combine(candidate.FullName, SolutionFileName))) + { + return candidate.FullName; + } + } + + throw new DirectoryNotFoundException( + $"Could not find the CheatEngine.Client repository root ({SolutionFileName}) above '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/CheatEngine.Client.Tests/Infrastructure/SdkPin.cs b/tests/CheatEngine.Client.Tests/Infrastructure/SdkPin.cs new file mode 100644 index 0000000..413c6a2 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Infrastructure/SdkPin.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Xml.Linq; + +namespace CheatEngine.Client.Tests.Infrastructure; + +/// +/// Reads the committed CheatEngine.SDK pin of eng/CheatEngineSdk.props. Guard cases derive the versions they +/// probe (the next major, a prerelease, a drifted patch, a version below the declared range) from it, so they keep +/// testing the same boundaries after the pin moves. +/// +internal static class SdkPin +{ + /// The single source of the pin. + internal const string PropsPath = "eng/CheatEngineSdk.props"; + + private static readonly Lazy Props = + new(static () => XDocument.Load(RepositoryLayout.Combine(PropsPath)), LazyThreadSafetyMode.ExecutionAndPublication); + + /// CheatEngineSdkVersion, the inclusive lower bound of the declared range. + internal static string Version => Property("CheatEngineSdkVersion"); + + /// CheatEngineSdkUpperBound, the exclusive upper bound of the declared range. + internal static string UpperBound => Property("CheatEngineSdkUpperBound"); + + /// The major version of the pin. + internal static int Major => Component(Version, 0); + + /// The minor version of the pin. + internal static int Minor => Component(Version, 1); + + /// The patch version of the pin. + internal static int Patch => Component(Version, 2); + + /// The major version of the upper bound: the first CheatEngine.SDK major this Client does not support. + internal static int UpperMajor => Component(UpperBound, 0); + + /// _CheatEngineClientSupportedSdkMajor: the one CheatEngine.SDK major this Client supports. + internal static int SupportedMajor => + int.Parse(Property("_CheatEngineClientSupportedSdkMajor"), NumberStyles.None, CultureInfo.InvariantCulture); + + private static string Property(string name) + { + XElement[] elements = Props.Value.Descendants(name).ToArray(); + Assert.True(elements.Length == 1, $"{PropsPath} must define {name} exactly once, but defines it {elements.Length} times."); + return elements[0].Value.Trim(); + } + + private static int Component(string version, int index) + { + string[] components = version.Split('.'); + Assert.True(components.Length == 3, $"'{version}' in {PropsPath} is not a stable major.minor.patch version."); + return int.Parse(components[index], NumberStyles.None, CultureInfo.InvariantCulture); + } +} diff --git a/tests/CheatEngine.Client.Tests/Infrastructure/TemporaryDirectory.cs b/tests/CheatEngine.Client.Tests/Infrastructure/TemporaryDirectory.cs index 56081c5..abaa3ec 100644 --- a/tests/CheatEngine.Client.Tests/Infrastructure/TemporaryDirectory.cs +++ b/tests/CheatEngine.Client.Tests/Infrastructure/TemporaryDirectory.cs @@ -25,11 +25,28 @@ internal string Path get; } + /// + /// Deletes the directory. NuGet extracts some files read-only, and a child process that has just exited can still hold + /// a handle, so read-only attributes are cleared and the deletion is retried; a directory that still cannot be deleted + /// is left for the operating system's temporary-file cleanup rather than failing the test run. + /// public void Dispose() { - if (Directory.Exists(Path)) + for (int attempt = 0; attempt < 3 && Directory.Exists(Path); attempt++) { - Directory.Delete(Path, true); + try + { + foreach (string file in Directory.EnumerateFiles(Path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(Path, true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Thread.Sleep(TimeSpan.FromMilliseconds(500 * (attempt + 1))); + } } } diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/AuthorizationManifestTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/AuthorizationManifestTests.cs new file mode 100644 index 0000000..f092456 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/AuthorizationManifestTests.cs @@ -0,0 +1,144 @@ +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Infrastructure; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The runner's authorization files, evaluated by the harness's own gate and fault switch (compiled in from +/// tests/CheatEngine.Client.LivePlugin.Qualification/Harness): what the runner writes is exactly what the harness +/// accepts inside Cheat Engine, for the declared target only and never for longer than the runner's 25 minutes. +/// +[SupportedOSPlatform("windows")] +public sealed class AuthorizationManifestTests : IDisposable +{ + private const int HostProcessId = 5100; + private const int TargetProcessId = 5200; + private const string TargetSha256 = "2DABEFFD5DD45A3DA79697D6B8A3B7942A9EFF0EA78519A313ECB29AD5A865E3"; + + private static readonly DateTimeOffset Now = new(2026, 9, 24, 10, 15, 30, TimeSpan.Zero); + + private readonly TemporaryDirectory _temporary = new("LiveQualificationAuthorization"); + + public void Dispose() + { + _temporary.Dispose(); + } + + [Fact] + public void TheHarnessGateAcceptsTheWrittenManifestForTheDeclaredTargetOnly() + { + string manifest = Write(AuthorizationManifestWriter.MaximumLifetime); + + AuthorizationDecision decision = QualificationAuthorization.Evaluate(new HostEnvironment(manifest, Now)); + + Assert.True(decision.IsAllowed, decision.Denial.ToString()); + Assert.Equal(TargetProcessId, decision.TargetProcessId); + Assert.Equal(TargetSha256, decision.TargetSha256); + Assert.Equal(Now + AuthorizationManifestWriter.MaximumLifetime, decision.ExpiresUtc); + Assert.True(decision.Allows(TargetProcessId)); + Assert.False(decision.Allows(HostProcessId)); + } + + [Fact] + public void TheManifestExpiresBeforeTheHarnessLimit() + { + string manifest = Write(AuthorizationManifestWriter.MaximumLifetime); + + AuthorizationDecision expired = QualificationAuthorization.Evaluate( + new HostEnvironment(manifest, Now + AuthorizationManifestWriter.MaximumLifetime)); + + Assert.True(AuthorizationManifestWriter.MaximumLifetime < QualificationAuthorization.MaximumLifetime); + Assert.Equal(AuthorizationDenial.ManifestExpired, expired.Denial); + Assert.Throws(() => Write(AuthorizationManifestWriter.MaximumLifetime + TimeSpan.FromSeconds(1))); + Assert.Throws(() => Write(TimeSpan.Zero)); + } + + [Fact] + public void TheManifestNamesTheExactHostAndTheSessionInputsCarryIt() + { + string manifest = Write(TimeSpan.FromMinutes(5)); + IReadOnlyDictionary inputs = AuthorizationManifestWriter.SessionInputs(manifest); + + Assert.Contains($"\"hostSha256\": \"{QualificationAuthorization.ExactCheatEngineSha256}\"", File.ReadAllText(manifest), + StringComparison.Ordinal); + Assert.Equal(LiveQualificationOptIn.Acknowledgement, QualificationAuthorization.Acknowledgement); + Assert.Equal(QualificationAuthorization.Acknowledgement, inputs[QualificationAuthorization.AcknowledgementVariable]); + Assert.Equal(manifest, inputs[QualificationAuthorization.ManifestVariable]); + Assert.All(inputs.Keys, static name => Assert.StartsWith("CE_SDK_LIVE_PROBE_", name, StringComparison.Ordinal)); + } + + [Fact] + public void AnotherHostIsRefusedByTheHarnessGate() + { + string manifest = Write(TimeSpan.FromMinutes(5)); + + AuthorizationDecision decision = QualificationAuthorization.Evaluate( + new HostEnvironment(manifest, Now, new ProcessImage(QualificationAuthorization.ExactCheatEngineSha256, "Amd64", "7.6.0.9999"))); + + Assert.Equal(AuthorizationDenial.HostMismatch, decision.Denial); + } + + [Theory] + [InlineData(nameof(FaultStage.Configure))] + [InlineData(nameof(FaultStage.ModuleOnDisabling))] + [InlineData(nameof(FaultStage.ModuleOnDisablingAndResourceCleanup))] + public void TheHarnessReadsTheWrittenFaultSwitch(string stageName) + { + FaultStage stage = Enum.Parse(stageName); + string plugin = _temporary.CreateDirectory("plugin-" + stage); + HostEnvironment environment = new(Write(TimeSpan.FromMinutes(5)), Now); + AuthorizationDecision allowed = QualificationAuthorization.Evaluate(environment); + + AuthorizationManifestWriter.WriteFaultSwitch(plugin, stage); + FaultDecision selected = QualificationFaultSwitch.Read(plugin, allowed, environment); + AuthorizationManifestWriter.RemoveFaultSwitch(plugin); + FaultDecision removed = QualificationFaultSwitch.Read(plugin, allowed, environment); + + Assert.Equal(new FaultDecision(stage, FaultSwitchReason.Selected), selected); + Assert.Equal(FaultDecision.NoFault, removed); + } + + private string Write(TimeSpan lifetime) + { + return AuthorizationManifestWriter.Write(Path.Combine(_temporary.Path, "authorization.json"), + QualificationAuthorization.ExactCheatEngineSha256, TargetProcessId, TargetSha256, Now, lifetime); + } + + /// The view the harness has from inside Cheat Engine, with the runner's session inputs. + private sealed class HostEnvironment(string manifestPath, DateTimeOffset now, ProcessImage? host = null) : IQualificationEnvironment + { + private readonly IReadOnlyDictionary _variables = AuthorizationManifestWriter.SessionInputs(manifestPath); + + public DateTimeOffset UtcNow => now; + + public int CurrentProcessId => HostProcessId; + + public bool Is64BitProcess => true; + + public string? GetVariable(string name) + { + return _variables.GetValueOrDefault(name); + } + + public bool TryReadFile(string path, out string text) + { + text = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + return File.Exists(path); + } + + public bool TryDescribeProcessImage(int processId, out ProcessImage image) + { + image = processId switch + { + HostProcessId => host ?? new ProcessImage(QualificationAuthorization.ExactCheatEngineSha256, "Amd64", + QualificationAuthorization.ExactCheatEngineFileVersion), + TargetProcessId => new ProcessImage(TargetSha256, "Amd64", null), + _ => default + }; + return processId is HostProcessId or TargetProcessId; + } + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/AuthorizationManifestWriter.cs b/tests/CheatEngine.Client.Tests/LiveQualification/AuthorizationManifestWriter.cs new file mode 100644 index 0000000..35c91e0 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/AuthorizationManifestWriter.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// Writes the files the qualification harness reads to authorize a session: the ce77-live-probe-v1 manifest +/// (, compiled in from the harness) that names the pinned host, the disposable +/// target and a short expiry, and, for fault scenarios, the liveprobe.fault.json switch +/// () next to the plugin. +/// +[SupportedOSPlatform("windows")] +internal static class AuthorizationManifestWriter +{ + /// The longest validity the runner writes; the harness accepts at most 30 minutes. + internal static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(25); + + private static readonly JsonWriterOptions WriterOptions = new() + { + Indented = true + }; + + /// Writes the manifest to and returns the path. + internal static string Write(string path, string hostSha256, int targetProcessId, string targetSha256, + DateTimeOffset now, TimeSpan lifetime) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(hostSha256); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(targetProcessId); + ArgumentException.ThrowIfNullOrWhiteSpace(targetSha256); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lifetime, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfGreaterThan(lifetime, MaximumLifetime); + + using MemoryStream buffer = new(); + using (Utf8JsonWriter json = new(buffer, WriterOptions)) + { + json.WriteStartObject(); + json.WriteString("schema", QualificationAuthorization.ManifestSchema); + json.WriteString("acknowledgement", QualificationAuthorization.Acknowledgement); + json.WriteString("hostSha256", hostSha256); + json.WriteNumber("targetProcessId", targetProcessId); + json.WriteString("targetSha256", targetSha256); + json.WriteBoolean("disposable", true); + json.WriteString("expiresUtc", (now + lifetime).ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)); + json.WriteEndObject(); + } + + File.WriteAllBytes(path, buffer.ToArray()); + return path; + } + + /// The process variables that hand the manifest to the harness inside Cheat Engine. + internal static IReadOnlyDictionary SessionInputs(string manifestPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + return new Dictionary(StringComparer.Ordinal) + { + [QualificationAuthorization.AcknowledgementVariable] = QualificationAuthorization.Acknowledgement, + [QualificationAuthorization.ManifestVariable] = manifestPath + }; + } + + /// Writes the fault switch next to the plugin, selecting , and returns its path. + internal static string WriteFaultSwitch(string pluginDirectory, FaultStage stage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + string path = Path.Combine(pluginDirectory, QualificationFaultSwitch.FileName); + string text = string.Create(CultureInfo.InvariantCulture, + $$"""{ "schema": "{{QualificationFaultSwitch.Schema}}", "throwIn": "{{stage}}" }"""); + File.WriteAllText(path, text, new UTF8Encoding(false)); + return path; + } + + /// Removes the fault switch next to the plugin, if any. + internal static void RemoveFaultSwitch(string pluginDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + File.Delete(Path.Combine(pluginDirectory, QualificationFaultSwitch.FileName)); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineInstallation.cs b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineInstallation.cs new file mode 100644 index 0000000..559919c --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineInstallation.cs @@ -0,0 +1,240 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection.PortableExecutable; +using System.Runtime.Versioning; +using System.Security.Cryptography; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// The exact Cheat Engine installation a live run qualifies against. +/// The profile id a qualification result names. +/// The file name of the host executable. +/// The upper-case SHA-256 of the host executable. +/// The file version resource of the host executable. +/// The COFF machine of the host executable. +/// The disposable targets, by file name, with their upper-case SHA-256. +[SupportedOSPlatform("windows")] +internal sealed record CheatEngineProfile( + string Profile, + string HostExecutable, + string HostSha256, + string HostFileVersion, + string HostMachine, + IReadOnlyDictionary TargetSha256) +{ + /// The x64 gtutorial target. + internal const string Target64 = "gtutorial-x86_64.exe"; + + /// The x86 gtutorial target. + internal const string Target32 = "gtutorial-i386.exe"; + + /// + /// Cheat Engine 7.7.0.10621 x64 with its two gtutorial targets, the profile the harness gate also pins + /// (QualificationAuthorization.ExactCheatEngineSha256). + /// + internal static CheatEngineProfile CheatEngine77 + { + get; + } = new("ce-7.7.0.10621-x64-managed-hostfxr", "cheatengine-x86_64.exe", + "9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D", "7.7.0.10621", "Amd64", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [Target64] = "2DABEFFD5DD45A3DA79697D6B8A3B7942A9EFF0EA78519A313ECB29AD5A865E3", + [Target32] = "9131B1CA916D6AC1FB67224A67CF0F578105D43AEEEBB03137E94D73CBE11BCA" + }); +} + +/// The PE facts the verification needs about an executable. +/// The COFF machine, for example Amd64. +/// The file version resource, or . +internal readonly record struct ExecutableFacts(string Machine, string? FileVersion); + +/// Reads ; tests substitute one for fake files. +internal interface IExecutableInspector +{ + /// Describes the executable at . + public ExecutableFacts Describe(string path); +} + +/// Reads the COFF machine with and the version resource with . +[SupportedOSPlatform("windows")] +internal sealed class PortableExecutableInspector : IExecutableInspector +{ + /// The shared instance. + internal static PortableExecutableInspector Instance + { + get; + } = new(); + + /// + public ExecutableFacts Describe(string path) + { + using FileStream stream = File.OpenRead(path); + using PEReader reader = new(stream); + return new ExecutableFacts(reader.PEHeaders.CoffHeader.Machine.ToString(), FileVersionInfo.GetVersionInfo(path).FileVersion); + } +} + +/// What must not change in the source installation: the host executable and the autorun folder. +/// The SHA-256 of the host executable. +/// One relative/path length SHA-256 line per autorun file, ordinally sorted. +internal sealed record InstallationFingerprint(string HostSha256, IReadOnlyList Autorun); + +/// +/// Verifies the source Cheat Engine installation without writing to it, copies it into the run's sandbox, verifies the +/// copy again, and proves after the run that the source is unchanged. Nothing here ever writes below the source. +/// +[SupportedOSPlatform("windows")] +internal static class CheatEngineInstallation +{ + /// The folder whose Lua files Cheat Engine runs at startup. + internal const string AutorunFolder = "autorun"; + + /// Every way differs from ; empty when it matches. + internal static IReadOnlyList Verify(string directory, CheatEngineProfile profile, IExecutableInspector inspector) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(inspector); + + List problems = []; + string host = Path.Combine(directory, profile.HostExecutable); + if (!File.Exists(host)) + { + problems.Add($"{profile.HostExecutable} is missing."); + } + else + { + string sha256 = Sha256(host); + if (!string.Equals(sha256, profile.HostSha256, StringComparison.Ordinal)) + { + problems.Add($"{profile.HostExecutable} has SHA-256 {sha256}, expected {profile.HostSha256}."); + } + + ExecutableFacts facts; + try + { + facts = inspector.Describe(host); + } + catch (BadImageFormatException) + { + facts = new ExecutableFacts("NotAPortableExecutable", null); + } + + if (!string.Equals(facts.Machine, profile.HostMachine, StringComparison.Ordinal)) + { + problems.Add($"{profile.HostExecutable} is built for {facts.Machine}, expected {profile.HostMachine}."); + } + + if (!string.Equals(facts.FileVersion, profile.HostFileVersion, StringComparison.Ordinal)) + { + problems.Add($"{profile.HostExecutable} has file version {facts.FileVersion ?? "(none)"}, expected {profile.HostFileVersion}."); + } + } + + foreach ((string target, string expected) in profile.TargetSha256.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + string path = Path.Combine(directory, target); + if (!File.Exists(path)) + { + problems.Add($"{target} is missing."); + continue; + } + + string sha256 = Sha256(path); + if (!string.Equals(sha256, expected, StringComparison.Ordinal)) + { + problems.Add($"{target} has SHA-256 {sha256}, expected {expected}."); + } + } + + return problems; + } + + /// The host hash and the autorun listing of . + internal static InstallationFingerprint Fingerprint(string directory, CheatEngineProfile profile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentNullException.ThrowIfNull(profile); + + string host = Path.Combine(directory, profile.HostExecutable); + string autorun = Path.Combine(directory, AutorunFolder); + List listing = []; + if (Directory.Exists(autorun)) + { + foreach (string file in Directory.EnumerateFiles(autorun, "*", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(autorun, file).Replace('\\', '/'); + listing.Add(string.Create(CultureInfo.InvariantCulture, $"{relative} {new FileInfo(file).Length} {Sha256(file)}")); + } + } + + listing.Sort(StringComparer.Ordinal); + return new InstallationFingerprint(File.Exists(host) ? Sha256(host) : "missing", listing); + } + + /// Every difference between two fingerprints; empty when the installation is unchanged. + internal static IReadOnlyList Compare(InstallationFingerprint before, InstallationFingerprint after) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + + List differences = []; + if (!string.Equals(before.HostSha256, after.HostSha256, StringComparison.Ordinal)) + { + differences.Add($"host executable SHA-256 {before.HostSha256} became {after.HostSha256}"); + } + + differences.AddRange(before.Autorun.Except(after.Autorun, StringComparer.Ordinal).Select(static line => $"autorun lost or changed: {line}")); + differences.AddRange(after.Autorun.Except(before.Autorun, StringComparer.Ordinal).Select(static line => $"autorun gained or changed: {line}")); + return differences; + } + + /// + /// Copies the whole source installation into (which must not exist yet), then + /// verifies the copy against the profile and proves that its autorun folder equals the source's. + /// + internal static void CopyTo(string source, string destination, CheatEngineProfile profile, IExecutableInspector inspector) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(destination); + ArgumentNullException.ThrowIfNull(profile); + if (Directory.Exists(destination) || File.Exists(destination)) + { + throw new IOException($"The sandbox '{destination}' already exists."); + } + + string root = Path.GetFullPath(source); + Directory.CreateDirectory(destination); + foreach (string directory in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(root, directory))); + } + + foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + string copy = Path.Combine(destination, Path.GetRelativePath(root, file)); + File.Copy(file, copy); + File.SetAttributes(copy, File.GetAttributes(copy) & ~FileAttributes.ReadOnly); + } + + IReadOnlyList problems = Verify(destination, profile, inspector); + if (problems.Count > 0) + { + throw new InvalidOperationException($"The sandbox copy differs from the profile: {string.Join(" ", problems)}"); + } + + IReadOnlyList autorun = Compare(Fingerprint(root, profile), Fingerprint(destination, profile)); + if (autorun.Count > 0) + { + throw new InvalidOperationException($"The sandbox copy differs from its source: {string.Join("; ", autorun)}."); + } + } + + /// The upper-case SHA-256 of a file. + internal static string Sha256(string path) + { + using FileStream stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineInstallationTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineInstallationTests.cs new file mode 100644 index 0000000..4c5ecb5 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineInstallationTests.cs @@ -0,0 +1,181 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Tests.Infrastructure; + +using LivePlugin.Qualification.Harness; + +using CpuArchitecture = System.Runtime.InteropServices.Architecture; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The source installation checks, on fake files in a temporary folder: the host hash, machine and version, the +/// target hashes, the sandbox copy and the before/after fingerprint that proves the source untouched. +/// +[SupportedOSPlatform("windows")] +public sealed partial class CheatEngineInstallationTests : IDisposable +{ + private readonly TemporaryDirectory _temporary = new("LiveQualificationInstallation"); + private readonly string _source; + private readonly CheatEngineProfile _profile; + + public CheatEngineInstallationTests() + { + _source = _temporary.CreateDirectory("Cheat Engine"); + File.WriteAllText(Path.Combine(_source, "cheatengine-x86_64.exe"), "host"); + File.WriteAllText(Path.Combine(_source, CheatEngineProfile.Target64), "target 64"); + File.WriteAllText(Path.Combine(_source, CheatEngineProfile.Target32), "target 32"); + Directory.CreateDirectory(Path.Combine(_source, "autorun", "forms")); + File.WriteAllText(Path.Combine(_source, "autorun", "celib.lua"), "-- lib"); + File.WriteAllText(Path.Combine(_source, "autorun", "forms", "form.frm"), "form"); + File.WriteAllText(Path.Combine(_source, "ce.runtimeconfig.json"), "{}"); + _profile = CheatEngineProfile.CheatEngine77 with + { + HostSha256 = CheatEngineInstallation.Sha256(Path.Combine(_source, "cheatengine-x86_64.exe")), + TargetSha256 = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [CheatEngineProfile.Target64] = CheatEngineInstallation.Sha256(Path.Combine(_source, CheatEngineProfile.Target64)), + [CheatEngineProfile.Target32] = CheatEngineInstallation.Sha256(Path.Combine(_source, CheatEngineProfile.Target32)) + } + }; + } + + public void Dispose() + { + _temporary.Dispose(); + } + + [Fact] + public void TheQualifiedProfileIsTheHostTheHarnessGatePins() + { + CheatEngineProfile profile = CheatEngineProfile.CheatEngine77; + + Assert.Equal(QualificationAuthorization.ExactCheatEngineSha256, profile.HostSha256); + Assert.Equal(QualificationAuthorization.ExactCheatEngineFileVersion, profile.HostFileVersion); + Assert.Equal("Amd64", profile.HostMachine); + Assert.Equal("ce-7.7.0.10621-x64-managed-hostfxr", profile.Profile); + Assert.StartsWith("2DABEFFD", profile.TargetSha256[CheatEngineProfile.Target64], StringComparison.Ordinal); + Assert.All(profile.TargetSha256.Values.Append(profile.HostSha256), static hash => Assert.Matches("^[0-9A-F]{64}$", hash)); + } + + [Fact] + public void AMatchingInstallationVerifies() + { + Assert.Empty(CheatEngineInstallation.Verify(_source, _profile, new FakeInspector("Amd64", "7.7.0.10621"))); + } + + [Fact] + public void EveryDifferenceFromTheProfileIsReported() + { + File.WriteAllText(Path.Combine(_source, "cheatengine-x86_64.exe"), "patched host"); + File.Delete(Path.Combine(_source, CheatEngineProfile.Target32)); + + IReadOnlyList problems = CheatEngineInstallation.Verify(_source, _profile, new FakeInspector("I386", "7.6.0.9999")); + + Assert.Collection(problems, + static problem => Assert.StartsWith("cheatengine-x86_64.exe has SHA-256", problem, StringComparison.Ordinal), + static problem => Assert.Equal("cheatengine-x86_64.exe is built for I386, expected Amd64.", problem), + static problem => Assert.Equal("cheatengine-x86_64.exe has file version 7.6.0.9999, expected 7.7.0.10621.", problem), + static problem => Assert.Equal("gtutorial-i386.exe is missing.", problem)); + } + + [Fact] + public void AMissingHostOrANonPortableExecutableIsReported() + { + IReadOnlyList notPe = CheatEngineInstallation.Verify(_source, _profile, PortableExecutableInspector.Instance); + File.Delete(Path.Combine(_source, "cheatengine-x86_64.exe")); + IReadOnlyList missing = CheatEngineInstallation.Verify(_source, _profile, PortableExecutableInspector.Instance); + + Assert.Contains("cheatengine-x86_64.exe is built for NotAPortableExecutable, expected Amd64.", notPe); + Assert.Equal(["cheatengine-x86_64.exe is missing."], missing); + } + + [Fact] + public void ThePortableExecutableInspectorReadsTheMachineOfARealImage() + { + ExecutableFacts facts = PortableExecutableInspector.Instance.Describe(Environment.ProcessPath!); + + string expected = RuntimeInformation.ProcessArchitecture switch + { + CpuArchitecture.X64 => "Amd64", + CpuArchitecture.Arm64 => "Arm64", + CpuArchitecture.X86 => "I386", + _ => RuntimeInformation.ProcessArchitecture.ToString() + }; + Assert.Equal(expected, facts.Machine); + } + + [Fact] + public void TheSandboxCopyIsVerifiedAndTheSourceIsLeftUnchanged() + { + InstallationFingerprint before = CheatEngineInstallation.Fingerprint(_source, _profile); + string sandbox = Path.Combine(_temporary.Path, "run", "ce"); + File.SetAttributes(Path.Combine(_source, "autorun", "celib.lua"), FileAttributes.ReadOnly); + + CheatEngineInstallation.CopyTo(_source, sandbox, _profile, new FakeInspector("Amd64", "7.7.0.10621")); + + Assert.Empty(CheatEngineInstallation.Compare(before, CheatEngineInstallation.Fingerprint(_source, _profile))); + Assert.Empty(CheatEngineInstallation.Compare(before, CheatEngineInstallation.Fingerprint(sandbox, _profile))); + Assert.Equal("{}", File.ReadAllText(Path.Combine(sandbox, "ce.runtimeconfig.json"))); + Assert.False(File.GetAttributes(Path.Combine(sandbox, "autorun", "celib.lua")).HasFlag(FileAttributes.ReadOnly)); + Assert.Throws(() => CheatEngineInstallation.CopyTo(_source, sandbox, _profile, new FakeInspector("Amd64", "7.7.0.10621"))); + File.SetAttributes(Path.Combine(_source, "autorun", "celib.lua"), FileAttributes.Normal); + } + + [Fact] + public void TheSandboxCopyIsRefusedWhenItDoesNotMatchTheProfile() + { + string sandbox = Path.Combine(_temporary.Path, "run", "ce"); + + InvalidOperationException refused = Assert.Throws(() => + CheatEngineInstallation.CopyTo(_source, sandbox, _profile, new FakeInspector("Amd64", "7.6.0.9999"))); + + Assert.Contains("file version 7.6.0.9999", refused.Message, StringComparison.Ordinal); + } + + [Fact] + public void TheFingerprintDetectsAChangedHostOrAutorunFolder() + { + InstallationFingerprint before = CheatEngineInstallation.Fingerprint(_source, _profile); + File.WriteAllText(Path.Combine(_source, "autorun", "zz_cheatengine_client_qualification.lua"), "-- driver"); + File.WriteAllText(Path.Combine(_source, "autorun", "celib.lua"), "-- changed"); + File.WriteAllText(Path.Combine(_source, "cheatengine-x86_64.exe"), "patched host"); + + IReadOnlyList changes = CheatEngineInstallation.Compare(before, CheatEngineInstallation.Fingerprint(_source, _profile)); + + Assert.Contains(changes, static change => change.StartsWith("host executable SHA-256", StringComparison.Ordinal)); + Assert.Contains(changes, static change => change.StartsWith("autorun lost or changed: celib.lua 6 ", StringComparison.Ordinal)); + Assert.Contains(changes, static change => change.StartsWith("autorun gained or changed: celib.lua 10 ", StringComparison.Ordinal)); + Assert.Contains(changes, static change => change.StartsWith("autorun gained or changed: zz_cheatengine_client_qualification.lua ", StringComparison.Ordinal)); + Assert.Contains("forms/form.frm 4 ", string.Join('\n', before.Autorun), StringComparison.Ordinal); + } + + [Fact] + public void EachRunGetsItsOwnTimestampedDirectory() + { + DateTimeOffset now = new(2026, 9, 24, 10, 15, 30, TimeSpan.Zero); + + SandboxLayout first = SandboxLayout.Create(Path.Combine(_temporary.Path, "runs"), now); + SandboxLayout second = SandboxLayout.Create(Path.Combine(_temporary.Path, "runs"), now); + + Assert.Matches(ExpectedRunId(), first.RunId); + Assert.NotEqual(first.RunDirectory, second.RunDirectory); + Assert.True(Directory.Exists(first.PluginsDirectory)); + Assert.Equal(Path.Combine(first.RunDirectory, "ce", "autorun", SandboxLayout.DriverScriptName), first.DriverScriptPath); + Assert.True(Directory.Exists(first.SessionDirectory("S0"))); + Assert.False(Directory.Exists(first.CheatEngineDirectory)); + } + + private sealed class FakeInspector(string machine, string? fileVersion) : IExecutableInspector + { + public ExecutableFacts Describe(string path) + { + return new ExecutableFacts(machine, fileVersion); + } + } + + [GeneratedRegex("^20260924T101530Z-[0-9a-f]{4}$")] + private static partial Regex ExpectedRunId(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineRegistryGuard.cs b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineRegistryGuard.cs new file mode 100644 index 0000000..5294f98 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineRegistryGuard.cs @@ -0,0 +1,424 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +using Microsoft.Win32; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// Where the Cheat Engine user state lives. Only two shapes are accepted, so no bug can point the guard's +/// DeleteSubKeyTree at another key: the real HKCU\Software\Cheat Engine with %APPDATA%\Cheat Engine, +/// or a test-owned scratch key HKCU\Software\CheatEngine.Client.Tests\<guid> with a folder below the +/// temporary directory. +/// +/// The key below HKEY_CURRENT_USER. +/// The Cheat Engine folder of the roaming application data. +[SupportedOSPlatform("windows")] +internal sealed record CheatEngineUserStateLocations(string RegistrySubKey, string AppDataDirectory) +{ + /// The Cheat Engine user key. + internal const string CheatEngineRegistrySubKey = @"Software\Cheat Engine"; + + /// The parent of the test-owned scratch keys. + internal const string ScratchRegistryParent = @"Software\CheatEngine.Client.Tests"; + + /// The workstation's real Cheat Engine user state. + internal static CheatEngineUserStateLocations Workstation => new(CheatEngineRegistrySubKey, + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Cheat Engine")); + + /// Throws unless is the Cheat Engine key or a scratch key. + internal static void RequireGuardedSubKey(string subKey) + { + ArgumentNullException.ThrowIfNull(subKey); + string[] segments = subKey.Split('\\'); + bool scratch = segments.Length == 3 && + string.Equals(string.Join('\\', segments[..2]), ScratchRegistryParent, StringComparison.OrdinalIgnoreCase) && + Guid.TryParseExact(segments[2], "N", out _); + if (!scratch && !string.Equals(subKey, CheatEngineRegistrySubKey, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"'HKCU\\{subKey}' is neither {CheatEngineRegistrySubKey} nor a scratch key {ScratchRegistryParent}\\.", + nameof(subKey)); + } + } + + /// Throws unless both locations have one of the accepted shapes, consistently. + internal void Validate() + { + RequireGuardedSubKey(RegistrySubKey); + string appData = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppDataDirectory)); + bool real = string.Equals(RegistrySubKey, CheatEngineRegistrySubKey, StringComparison.OrdinalIgnoreCase); + bool accepted = real + ? string.Equals(appData, Path.TrimEndingDirectorySeparator(Workstation.AppDataDirectory), StringComparison.OrdinalIgnoreCase) + : LiveQualificationOptIn.IsSameOrBelow(appData, Path.GetTempPath()) && + !string.Equals(appData, Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())), StringComparison.OrdinalIgnoreCase); + if (!accepted) + { + throw new ArgumentException(real + ? $"The Cheat Engine key goes with %APPDATA%\\Cheat Engine, not '{appData}'." + : $"A scratch key goes with a folder below the temporary directory, not '{appData}'."); + } + } +} + +/// The files and folders of a directory: relative/path length SHA-256 per file and relative/ per folder. +internal sealed record FileTreeSnapshot(bool Exists, IReadOnlyList Entries); + +/// +/// Backs up and restores a folder byte for byte: the backup is a copy plus a listing +/// (cheatengine-client-appdata-backup/v1); restoring deletes the folder, copies the backup back (or leaves it +/// absent when it was absent) and proves the listing equal. +/// +[SupportedOSPlatform("windows")] +internal static class FileTreeBackup +{ + /// The listing schema. + internal const string Schema = "cheatengine-client-appdata-backup/v1"; + + /// Lists . + internal static FileTreeSnapshot Capture(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + if (!Directory.Exists(directory)) + { + return new FileTreeSnapshot(false, []); + } + + List entries = []; + foreach (string folder in Directory.EnumerateDirectories(directory, "*", SearchOption.AllDirectories)) + { + entries.Add(Path.GetRelativePath(directory, folder).Replace('\\', '/') + "/"); + } + + foreach (string file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + entries.Add(string.Create(CultureInfo.InvariantCulture, + $"{Path.GetRelativePath(directory, file).Replace('\\', '/')} {new FileInfo(file).Length} {CheatEngineInstallation.Sha256(file)}")); + } + + entries.Sort(StringComparer.Ordinal); + return new FileTreeSnapshot(true, entries); + } + + /// Copies to , if it exists, and returns its listing. + internal static FileTreeSnapshot Backup(string directory, string backup) + { + FileTreeSnapshot snapshot = Capture(directory); + if (snapshot.Exists) + { + CopyTree(directory, backup); + } + + FileTreeSnapshot copy = Capture(backup); + if (snapshot.Exists && !copy.Entries.SequenceEqual(snapshot.Entries, StringComparer.Ordinal)) + { + throw new InvalidOperationException($"The backup of '{directory}' differs from it."); + } + + return snapshot; + } + + /// Replaces with the backup and verifies it against . + internal static void Restore(string directory, string backup, FileTreeSnapshot expected) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentNullException.ThrowIfNull(expected); + if (Directory.Exists(directory)) + { + foreach (string file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(directory, true); + } + + if (expected.Exists) + { + CopyTree(backup, directory); + } + + FileTreeSnapshot restored = Capture(directory); + if (restored.Exists != expected.Exists || !restored.Entries.SequenceEqual(expected.Entries, StringComparer.Ordinal)) + { + throw new InvalidOperationException($"'{directory}' differs from its backup after the restore."); + } + } + + /// The listing as JSON. + internal static string Serialize(string directory, FileTreeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + using MemoryStream buffer = new(); + using (Utf8JsonWriter json = new(buffer, CheatEngineRegistryGuard.WriterOptions)) + { + json.WriteStartObject(); + json.WriteString("schema", Schema); + json.WriteString("directory", directory); + json.WriteBoolean("exists", snapshot.Exists); + json.WriteStartArray("entries"); + foreach (string entry in snapshot.Entries) + { + json.WriteStringValue(entry); + } + + json.WriteEndArray(); + json.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + /// Reads a listing written by . + internal static FileTreeSnapshot Parse(string text) + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + if (!string.Equals(root.GetProperty("schema").GetString(), Schema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"The folder listing does not declare {Schema}."); + } + + return new FileTreeSnapshot(root.GetProperty("exists").GetBoolean(), + [.. root.GetProperty("entries").EnumerateArray().Select(static entry => entry.GetString()!)]); + } + + private static void CopyTree(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (string folder in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, folder))); + } + + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + File.Copy(file, Path.Combine(destination, Path.GetRelativePath(source, file)), true); + } + } +} + +/// +/// Backs up the Cheat Engine user state before a session and restores it, verified, afterwards: a recursive snapshot +/// of the registry key to <run>/hkcu-backup.json and a copy of the %APPDATA% folder. Before anything +/// may change, it writes the crash marker registry-restore-pending.json in the run root, naming the backups. The +/// restore deletes the key tree, recreates it and proves it equal to the backup, does the same for the folder, and only +/// then removes the marker. A marker found when a session begins means a previous run crashed: the guard restores +/// that run's backup first, verifies it, and fails the new run so the operator sees what happened. +/// +[SupportedOSPlatform("windows")] +internal sealed class CheatEngineRegistryGuard : ICheatEngineUserStateGuard +{ + /// The marker schema. + internal const string MarkerSchema = "cheatengine-client-user-state-marker/v1"; + + /// Indented JSON for every backup file. + internal static readonly JsonWriterOptions WriterOptions = new() + { + Indented = true + }; + + private readonly CheatEngineUserStateLocations _locations; + private readonly IReadOnlyList _neutralizedSubKeys; + private readonly IReadOnlyList _neutralizedValues; + + /// + /// Creates a guard of . During a session it removes the listed subkeys and values of + /// the key (the operator's plugin list, once the S0 spike has named it), which the restore brings back. + /// + internal CheatEngineRegistryGuard(CheatEngineUserStateLocations locations, IReadOnlyList neutralizedSubKeys, + IReadOnlyList neutralizedValues) + { + ArgumentNullException.ThrowIfNull(locations); + ArgumentNullException.ThrowIfNull(neutralizedSubKeys); + ArgumentNullException.ThrowIfNull(neutralizedValues); + locations.Validate(); + _locations = locations; + _neutralizedSubKeys = neutralizedSubKeys; + _neutralizedValues = neutralizedValues; + } + + /// + /// The guard of the workstation's real Cheat Engine user state. It neutralizes nothing yet: the plugin-list values + /// are among the facts the S0 spike records. + /// + internal static CheatEngineRegistryGuard ForWorkstation() + { + return new CheatEngineRegistryGuard(CheatEngineUserStateLocations.Workstation, [], []); + } + + /// + public ICheatEngineUserStateScope Begin(SandboxLayout layout) + { + ArgumentNullException.ThrowIfNull(layout); + RecoverLeftover(layout.RestoreMarkerPath); + + RegistryTreeSnapshot registry = RegistrySnapshot.Capture(_locations.RegistrySubKey); + string registryText = RegistrySnapshot.Serialize(registry); + File.WriteAllText(layout.RegistryBackupPath, registryText, new UTF8Encoding(false)); + if (!string.Equals(RegistrySnapshot.Serialize(RegistrySnapshot.Parse(File.ReadAllText(layout.RegistryBackupPath))), registryText, + StringComparison.Ordinal)) + { + throw new InvalidOperationException("The registry backup does not read back equal to the key."); + } + + FileTreeSnapshot appData = FileTreeBackup.Backup(_locations.AppDataDirectory, layout.AppDataBackupDirectory); + File.WriteAllText(layout.AppDataManifestPath, FileTreeBackup.Serialize(_locations.AppDataDirectory, appData), new UTF8Encoding(false)); + + Marker marker = new(layout.RunId, _locations.RegistrySubKey, layout.RegistryBackupPath, _locations.AppDataDirectory, + layout.AppDataManifestPath, layout.AppDataBackupDirectory); + marker.Write(layout.RestoreMarkerPath); + Neutralize(); + return new Scope(marker, layout.RestoreMarkerPath); + } + + /// Restores the backups a marker names, verifies them, then removes the marker. + private static void RestoreFromMarker(Marker marker, string markerPath) + { + RegistryTreeSnapshot registry = RegistrySnapshot.Parse(File.ReadAllText(marker.RegistryBackup)); + if (!string.Equals(registry.SubKey, marker.RegistrySubKey, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"The registry backup of run {marker.RunId} names another key than its marker."); + } + + RegistrySnapshot.Restore(registry); + FileTreeBackup.Restore(marker.AppDataDirectory, marker.AppDataBackup, FileTreeBackup.Parse(File.ReadAllText(marker.AppDataManifest))); + File.Delete(markerPath); + } + + private void RecoverLeftover(string markerPath) + { + if (!File.Exists(markerPath)) + { + return; + } + + Marker marker = Marker.Read(markerPath); + if (!string.Equals(marker.RegistrySubKey, _locations.RegistrySubKey, StringComparison.OrdinalIgnoreCase) || + !string.Equals(Path.GetFullPath(marker.AppDataDirectory), Path.GetFullPath(_locations.AppDataDirectory), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"The restore marker '{markerPath}' names another user state than this guard; restore it by hand."); + } + + try + { + RestoreFromMarker(marker, markerPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException + or InvalidDataException or JsonException) + { + throw new InvalidOperationException( + $"Run {marker.RunId} ended without restoring the Cheat Engine user state, and restoring its backup failed now; " + + $"the marker '{markerPath}' is kept. Restore HKCU\\{marker.RegistrySubKey} from '{marker.RegistryBackup}' and " + + $"'{marker.AppDataDirectory}' from '{marker.AppDataBackup}' before any other session.", exception); + } + + throw new InvalidOperationException( + $"Run {marker.RunId} ended without restoring the Cheat Engine user state. Its backup has now been restored and " + + "verified and its marker removed; this run stops here. Run the session again."); + } + + private void Neutralize() + { + if (_neutralizedSubKeys.Count == 0 && _neutralizedValues.Count == 0) + { + return; + } + + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(_locations.RegistrySubKey, true); + if (key is null) + { + return; + } + + foreach (string subKey in _neutralizedSubKeys) + { + key.DeleteSubKeyTree(subKey, false); + } + + foreach (string value in _neutralizedValues) + { + key.DeleteValue(value, false); + } + } + + /// The crash marker: which run's backups to restore, and where they are. + private sealed record Marker( + string RunId, + string RegistrySubKey, + string RegistryBackup, + string AppDataDirectory, + string AppDataManifest, + string AppDataBackup) + { + internal static Marker Read(string path) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement root = document.RootElement; + if (!string.Equals(root.GetProperty("schema").GetString(), MarkerSchema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"'{path}' does not declare {MarkerSchema}."); + } + + return new Marker(Text(root, "runId"), Text(root, "registrySubKey"), Text(root, "registryBackup"), + Text(root, "appDataDirectory"), Text(root, "appDataManifest"), Text(root, "appDataBackup")); + } + + /// Writes the marker atomically: a reader sees the whole marker or none. + internal void Write(string path) + { + using MemoryStream buffer = new(); + using (Utf8JsonWriter json = new(buffer, WriterOptions)) + { + json.WriteStartObject(); + json.WriteString("schema", MarkerSchema); + json.WriteString("runId", RunId); + json.WriteString("registrySubKey", RegistrySubKey); + json.WriteString("registryBackup", RegistryBackup); + json.WriteString("appDataDirectory", AppDataDirectory); + json.WriteString("appDataManifest", AppDataManifest); + json.WriteString("appDataBackup", AppDataBackup); + json.WriteEndObject(); + } + + string temporary = path + "." + RandomNumberGenerator.GetHexString(8, true) + ".tmp"; + File.WriteAllBytes(temporary, buffer.ToArray()); + File.Move(temporary, path, false); + } + + private static string Text(JsonElement root, string name) + { + return root.GetProperty(name).GetString() ?? throw new InvalidDataException($"The restore marker has no '{name}'."); + } + } + + /// A backed-up session: puts the user state back and removes the marker. + private sealed class Scope(Marker marker, string markerPath) : ICheatEngineUserStateScope + { + private bool _attempted; + + public bool Restored + { + get; + private set; + } + + public void Restore() + { + _attempted = true; + RestoreFromMarker(marker, markerPath); + Restored = true; + } + + public void Dispose() + { + if (!_attempted) + { + Restore(); + } + } + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineUserState.cs b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineUserState.cs new file mode 100644 index 0000000..c86cefb --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/CheatEngineUserState.cs @@ -0,0 +1,25 @@ +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// Protects the Cheat Engine state of the workstation user (HKCU\Software\Cheat Engine and the Cheat Engine +/// files of %APPDATA%) around a session: backs it up before Cheat Engine starts, and the +/// returned scope restores it, verified, afterwards. +/// +internal interface ICheatEngineUserStateGuard +{ + /// Backs up the user state of 's run and returns the scope that restores it. + public ICheatEngineUserStateScope Begin(SandboxLayout layout); +} + +/// A backed-up user state; disposing it restores and verifies the state if did not. +internal interface ICheatEngineUserStateScope : IDisposable +{ + /// Whether the state was restored and verified equal to the backup. + public bool Restored + { + get; + } + + /// Restores the backup and verifies it; throws when the restored state differs. + public void Restore(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/DebugOutputBufferTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/DebugOutputBufferTests.cs new file mode 100644 index 0000000..35136df --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/DebugOutputBufferTests.cs @@ -0,0 +1,74 @@ +using System.Buffers.Binary; +using System.Runtime.Versioning; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The DBWIN buffer decoder: the writer's process id, then an ANSI string ended by NUL, kept only for the sandboxed +/// Cheat Engine so the debug output of other processes is never stored. +/// +[SupportedOSPlatform("windows")] +public sealed class DebugOutputBufferTests +{ + private static readonly Encoding Windows1252 = CodePagesEncodingProvider.Instance.GetEncoding(1252)!; + + [Fact] + public void TheProcessIdAndTheAnsiTextAreDecoded() + { + byte[] buffer = Buffer(4242, [.. "[HostLog] caf"u8, 0xE9, (byte) '\r', (byte) '\n', 0, .. "stale bytes"u8]); + + Assert.True(DebugOutputBuffer.TryDecode(buffer, Windows1252, out DebugOutputMessage message)); + Assert.Equal(new DebugOutputMessage(4242, "[HostLog] café"), message); + } + + [Fact] + public void AMessageWithoutTerminatorUsesTheWholeBufferAndAShortBufferIsRejected() + { + Assert.True(DebugOutputBuffer.TryDecode(Buffer(7, "tail"u8.ToArray()), Encoding.ASCII, out DebugOutputMessage message)); + Assert.Equal("tail", message.Text); + Assert.False(DebugOutputBuffer.TryDecode([1, 2, 3], Encoding.ASCII, out _)); + } + + [Fact] + public void OnlyTheTrackedProcessesAreKept() + { + DebugOutputBuffer output = new(Encoding.ASCII); + + bool beforeTracking = output.Accept(Buffer(10, "early"u8.ToArray())); + output.Track(10); + bool tracked = output.Accept(Buffer(10, "identify"u8.ToArray())); + bool other = output.Accept(Buffer(11, "someone else"u8.ToArray())); + + Assert.False(beforeTracking); + Assert.True(tracked); + Assert.False(other); + Assert.Equal([new DebugOutputMessage(10, "identify")], output.Messages); + Assert.Equal(2, output.Ignored); + Assert.Equal(0, output.Dropped); + } + + [Fact] + public void TheKeptMessagesAreBounded() + { + DebugOutputBuffer output = new(Encoding.ASCII); + output.Track(10); + byte[] buffer = Buffer(10, "line"u8.ToArray()); + + for (int index = 0; index <= DebugOutputBuffer.MaximumMessages; index++) + { + output.Accept(buffer); + } + + Assert.Equal(DebugOutputBuffer.MaximumMessages, output.Messages.Count); + Assert.Equal(1, output.Dropped); + } + + private static byte[] Buffer(int processId, byte[] text) + { + byte[] buffer = new byte[sizeof(int) + text.Length]; + BinaryPrimitives.WriteInt32LittleEndian(buffer, processId); + text.CopyTo(buffer, sizeof(int)); + return buffer; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/DebugOutputCapture.cs b/tests/CheatEngine.Client.Tests/LiveQualification/DebugOutputCapture.cs new file mode 100644 index 0000000..30e8780 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/DebugOutputCapture.cs @@ -0,0 +1,252 @@ +using System.Buffers.Binary; +using System.IO.MemoryMappedFiles; +using System.Runtime.Versioning; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// One OutputDebugString message. +/// The process that wrote it. +/// The decoded text, without its trailing line break. +internal readonly record struct DebugOutputMessage(int ProcessId, string Text); + +/// +/// Decodes DBWIN buffers and keeps only the messages of the tracked processes (the sandboxed Cheat Engine), so the +/// debug output of any other process on the workstation is counted but never stored. A DBWIN buffer is 4096 bytes: +/// the writer's process id (little-endian DWORD), then an ANSI string terminated by NUL. +/// +internal sealed class DebugOutputBuffer +{ + /// The size of the DBWIN shared buffer. + internal const int BufferSize = 4096; + + /// The most messages kept; later ones are counted as dropped. + internal const int MaximumMessages = 100_000; + + private readonly Encoding _ansi; + private readonly Lock _gate = new(); + private readonly HashSet _tracked = []; + private readonly List _messages = []; + private int _ignored; + private int _dropped; + + /// Creates a buffer that decodes text with , the writer's ANSI code page. + internal DebugOutputBuffer(Encoding ansi) + { + ArgumentNullException.ThrowIfNull(ansi); + _ansi = ansi; + } + + /// The kept messages, in arrival order. + internal IReadOnlyList Messages + { + get + { + lock (_gate) + { + return [.. _messages]; + } + } + } + + /// How many messages of untracked processes were discarded. + internal int Ignored + { + get + { + lock (_gate) + { + return _ignored; + } + } + } + + /// How many messages of tracked processes exceeded . + internal int Dropped + { + get + { + lock (_gate) + { + return _dropped; + } + } + } + + /// The system ANSI code page, which OutputDebugStringW converts to; Latin-1 when unavailable. + internal static Encoding SystemAnsiEncoding() + { + return CodePagesEncodingProvider.Instance.GetEncoding(0) ?? Encoding.Latin1; + } + + /// Decodes one DBWIN buffer; when it is too short to hold a process id. + internal static bool TryDecode(ReadOnlySpan buffer, Encoding ansi, out DebugOutputMessage message) + { + ArgumentNullException.ThrowIfNull(ansi); + message = default; + if (buffer.Length < sizeof(int)) + { + return false; + } + + int processId = BinaryPrimitives.ReadInt32LittleEndian(buffer); + ReadOnlySpan text = buffer[sizeof(int)..]; + int terminator = text.IndexOf((byte) 0); + if (terminator >= 0) + { + text = text[..terminator]; + } + + message = new DebugOutputMessage(processId, ansi.GetString(text).TrimEnd('\r', '\n')); + return true; + } + + /// Keeps the messages of from now on. + internal void Track(int processId) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(processId); + lock (_gate) + { + _tracked.Add(processId); + } + } + + /// Decodes one buffer and keeps it when its writer is tracked; returns whether it was kept. + internal bool Accept(ReadOnlySpan buffer) + { + if (!TryDecode(buffer, _ansi, out DebugOutputMessage message)) + { + return false; + } + + lock (_gate) + { + if (!_tracked.Contains(message.ProcessId)) + { + _ignored++; + return false; + } + + if (_messages.Count >= MaximumMessages) + { + _dropped++; + return false; + } + + _messages.Add(message); + return true; + } + } +} + +/// +/// A managed DBWIN listener, the protocol DebugView implements: it owns the DBWIN_BUFFER section (4096 bytes) +/// and the DBWIN_BUFFER_READY and DBWIN_DATA_READY events, signals ready, waits for data and hands every +/// buffer to a filtered on the Cheat Engine process id. It captures the SDK host log, +/// the identification line of CHEATENGINE_SDK_IDENTIFY_ON_ENABLE and the cleanup Cheat Engine runs at +/// closeCE. It refuses to start when another listener already owns the objects. +/// +[SupportedOSPlatform("windows")] +internal sealed class DebugOutputCapture : IDisposable +{ + /// The shared section name. + internal const string BufferName = "DBWIN_BUFFER"; + + /// The event the listener signals when the section may be written. + internal const string BufferReadyName = "DBWIN_BUFFER_READY"; + + /// The event a writer signals when the section holds a message. + internal const string DataReadyName = "DBWIN_DATA_READY"; + + private readonly MemoryMappedFile _section; + private readonly MemoryMappedViewAccessor _view; + private readonly EventWaitHandle _bufferReady; + private readonly EventWaitHandle _dataReady; + private readonly ManualResetEvent _stop = new(false); + private readonly Thread _listener; + + private DebugOutputCapture(MemoryMappedFile section, EventWaitHandle bufferReady, EventWaitHandle dataReady, Encoding ansi) + { + _section = section; + _view = section.CreateViewAccessor(0, DebugOutputBuffer.BufferSize, MemoryMappedFileAccess.Read); + _bufferReady = bufferReady; + _dataReady = dataReady; + Buffer = new DebugOutputBuffer(ansi); + _listener = new Thread(Listen) + { + IsBackground = true, + Name = "DBWIN listener" + }; + _listener.Start(); + } + + /// The captured messages. + internal DebugOutputBuffer Buffer + { + get; + } + + /// Owns the DBWIN objects and starts listening. + internal static DebugOutputCapture Start(Encoding ansi) + { + ArgumentNullException.ThrowIfNull(ansi); + EventWaitHandle bufferReady = new(false, EventResetMode.AutoReset, BufferReadyName, out bool bufferReadyCreated); + EventWaitHandle? dataReady = null; + MemoryMappedFile? section = null; + try + { + dataReady = new EventWaitHandle(false, EventResetMode.AutoReset, DataReadyName, out bool dataReadyCreated); + if (!bufferReadyCreated || !dataReadyCreated) + { + throw new InvalidOperationException("Another debug output listener (for example DebugView) owns the DBWIN events; close it."); + } + + section = MemoryMappedFile.CreateNew(BufferName, DebugOutputBuffer.BufferSize, MemoryMappedFileAccess.ReadWrite); + return new DebugOutputCapture(section, bufferReady, dataReady, ansi); + } + catch + { + section?.Dispose(); + dataReady?.Dispose(); + bufferReady.Dispose(); + throw; + } + } + + /// Writes the messages of , one per line, to . + internal void WriteTo(string path, int processId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + IEnumerable lines = Buffer.Messages.Where(message => message.ProcessId == processId).Select(static message => message.Text); + File.WriteAllLines(path, lines, new UTF8Encoding(false)); + } + + /// + public void Dispose() + { + _stop.Set(); + _listener.Join(TimeSpan.FromSeconds(5)); + _view.Dispose(); + _section.Dispose(); + _dataReady.Dispose(); + _bufferReady.Dispose(); + _stop.Dispose(); + } + + private void Listen() + { + byte[] data = new byte[DebugOutputBuffer.BufferSize]; + WaitHandle[] handles = [_dataReady, _stop]; + while (true) + { + _bufferReady.Set(); + if (WaitHandle.WaitAny(handles) != 0) + { + return; + } + + _view.ReadArray(0, data, 0, data.Length); + Buffer.Accept(data); + } + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/EvaluatorTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/EvaluatorTests.cs new file mode 100644 index 0000000..1da6c97 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/EvaluatorTests.cs @@ -0,0 +1,403 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The evaluators on canned evidence: each check passes on the observation it requires and fails on its opposite, +/// a missing step is NotExecuted, an operator step the driver did not see done keeps its check NotExecuted, and +/// nothing that depends on a fact the spike records is guessed. +/// +[SupportedOSPlatform("windows")] +public sealed class EvaluatorTests +{ + [Fact] + public void AnIndeterminateGlobalZeroPassesAndAFactualZeroDoesNot() + { + QualificationCheck check = Check("Q27", "S1", "absent-pattern-indeterminate"); + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(("aob-absent", + """{"ok":false,"route":{"hostOutcome":"NoResult"},"failure":{"kind":"IndeterminateHostResult"},"checks":{"globalZeroIsIndeterminate":true}}"""))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("aob-absent", + """{"ok":true,"route":{"hostOutcome":"NoMatches"},"checks":{"globalZeroIsIndeterminate":false}}"""))).Status); + } + + [Fact] + public void AModuleScanMustBeBoundedVerifiedAndExact() + { + QualificationCheck check = Check("Q28", "S1", "module-scan-exact"); + const string Exact = """{"route":{"scope":"HostBoundedRange","reason":"ScopedRequestOnQualifiedTarget","targetIdentityVerified":true},"moduleCheck":{"containsBase":true,"filterExact":true,"globalComplete":true}}"""; + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(("aob-module", Exact))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("aob-module", + Exact.Replace("\"filterExact\":true", "\"filterExact\":false", StringComparison.Ordinal)))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("aob-module", + Exact.Replace("HostBoundedRange", "GlobalHostScanWithManagedFilter", StringComparison.Ordinal)))).Status); + } + + [Theory] + [InlineData(true, true, false, 12, true, "rule: ordinary rounding")] + [InlineData(false, false, false, 12, true, "rule: the documented range")] + [InlineData(true, false, false, 12, false, "float and double disagree")] + [InlineData(true, true, true, 12, false, "unmet: DoubleFloat with 2 decimals")] + [InlineData(true, true, false, 11, false, "11 cases")] + public void TheDecimalToleranceMeetsEveryExpectationAndNamesTheRoundingRule(bool singleFound, bool doubleFound, + bool lastFound, int count, bool passes, string observation) + { + QualificationCheck check = Check("Q25", "S1", "decimal-tolerance"); + StringBuilder cases = new("""{"ok":true,"cases":["""); + for (int index = 0; index < count; index++) + { + // Per type: 5 decimals, the discriminating 3 decimals, 2, 0, then 3.2 and 3.15, which must not match. + string type = index < 6 ? "SingleFloat" : "DoubleFloat"; + string decimals = (index % 6) switch + { + 0 => "5", + 1 => "3", + 2 => "2", + 3 => "0", + 4 => "1", + _ => "2" + }; + string item = index % 6 == 1 + ? "{\"type\":\"" + type + "\",\"decimals\":3,\"discriminating\":true,\"expectedFound\":null,\"scanned\":true," + + "\"found\":" + ((index < 6 ? singleFound : doubleFound) ? "true" : "false") + "}" + : "{\"type\":\"" + type + "\",\"decimals\":" + decimals + ",\"discriminating\":false,\"expectedFound\":" + + (index % 6 < 4 ? "true" : "false") + ",\"scanned\":true,\"found\":" + + ((index == 11 ? lastFound : index % 6 < 4) ? "true" : "false") + "}"; + cases.Append(index == 0 ? string.Empty : ",").Append(item); + } + + CheckResult result = check.Evaluate(Evidence(("value-scan-decimals", cases.Append("]}").ToString()))); + + Assert.Equal(passes ? ReceiptStatus.Passed : ReceiptStatus.Failed, result.Status); + Assert.Contains(observation, result.Observation, StringComparison.Ordinal); + } + + [Fact] + public void TheAutoAssemblerPolicyRefusalNeedsNoClientAndAMissingPolicyGate() + { + QualificationCheck check = Check("Q44", "S2", "auto-assembler-policy-refused"); + const string Refused = """{"ok":true,"processUnchanged":true,"families":[{"capability":"Client.AutoAssemblerPatches","state":"Unavailable","gates":{"policy":"Missing"},"policyRefusal":{"serviceRegistered":false}}]}"""; + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(("capabilities-policy", Refused))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("capabilities-policy", + Refused.Replace("\"serviceRegistered\":false", "\"serviceRegistered\":true", StringComparison.Ordinal)))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("capabilities-policy", + Refused.Replace("\"Missing\"", "\"Satisfied\"", StringComparison.Ordinal)))).Status); + } + + [Fact] + public void ARefusedReleaseAfterATargetChangeEndsTheLease() + { + QualificationCheck check = Check("Q30.a", "S3", "refused-after-target-change"); + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(("allocation-state-on-b", + """{"lease":{"released":true,"requiresManualRecovery":true},"lastRelease":{"kind":"RefusedTargetChanged"}}"""))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("allocation-state-on-b", + """{"lease":{"released":true,"requiresManualRecovery":false},"lastRelease":{"kind":"Released"}}"""))).Status); + } + + [Theory] + [InlineData(4243, 2, true)] + [InlineData(4242, 2, false)] + [InlineData(4243, 1, false)] + [InlineData(4243, 3, false)] + public void ATargetSwitchReportsTheSelectedProcessWithALaterEpoch(int processOnB, int epochOnB, bool passes) + { + QualificationCheck check = Check("Q32", "S3", "target-change-observed"); + static string Runtime(int processId, int epoch) + { + return "{\"ok\":true,\"process\":{\"processId\":" + processId.ToString(CultureInfo.InvariantCulture) + + ",\"selectionEpoch\":" + epoch.ToString(CultureInfo.InvariantCulture) + "}}"; + } + + CheckResult result = check.Evaluate(Evidence(("opened-process-a", "4242"), ("runtime-on-a", Runtime(4242, 1)), + ("opened-process-b", "4243"), ("runtime-on-b", Runtime(processOnB, epochOnB)), + ("opened-process-a-again", "4242"), ("runtime-back-on-a", Runtime(4242, 3)))); + + Assert.Equal(passes ? ReceiptStatus.Passed : ReceiptStatus.Failed, result.Status); + Assert.Contains("opened-process-b=4243: runtime-on-b process " + processOnB.ToString(CultureInfo.InvariantCulture), + result.Observation, StringComparison.Ordinal); + Assert.Equal(ReceiptStatus.NotExecuted, check.Evaluate(Evidence(("opened-process-a", "4242"), + ("runtime-on-a", Runtime(4242, 1)))).Status); + } + + [Fact] + public void ALeaseEndedByATargetChangeIsNeverReleasedOnTheOtherProcess() + { + const string Ended = """{"ok":true,"session":{"state":"Closed","released":true},"lastRelease":{"kind":"RefusedTargetChanged","requiresManualRecovery":true}}"""; + const string ReleasedOnB = """{"ok":true,"release":{"kind":"RefusedTargetChanged","hostEffect":"NotStarted"},"lastRelease":{"kind":"RefusedTargetChanged","requiresManualRecovery":true}}"""; + const string FreedOnB = """{"ok":true,"release":{"kind":"Released","hostEffect":"NotStarted"},"lastRelease":{"kind":"RefusedTargetChanged","requiresManualRecovery":true}}"""; + const string AllocationReleasedOnB = """{"ok":true,"releasedBefore":true,"release":{"kind":"RefusedTargetChanged","hostEffect":"NotStarted"},"requiresManualRecovery":true}"""; + const string AllocationFreedOnB = """{"ok":true,"releasedBefore":false,"release":{"kind":"Released","hostEffect":"Completed"},"requiresManualRecovery":false}"""; + + Assert.Equal(ReceiptStatus.Passed, Check("Q26", "S3", "refused-after-target-change") + .Evaluate(Evidence(("value-scan-state-on-b", Ended))).Status); + Assert.Equal(ReceiptStatus.Failed, Check("Q26", "S3", "refused-after-target-change") + .Evaluate(Evidence(("value-scan-state-on-b", Ended.Replace("RefusedTargetChanged", "Released", StringComparison.Ordinal)))).Status); + Assert.Equal(ReceiptStatus.Passed, Check("Q26", "S3", "nothing-released-on-b") + .Evaluate(Evidence(("value-scan-release-on-b", ReleasedOnB))).Status); + Assert.Equal(ReceiptStatus.Failed, Check("Q26", "S3", "nothing-released-on-b") + .Evaluate(Evidence(("value-scan-release-on-b", FreedOnB))).Status); + Assert.Equal(ReceiptStatus.Passed, Check("Q35", "S3", "nothing-disabled-on-b") + .Evaluate(Evidence(("aa-release-on-b", ReleasedOnB))).Status); + Assert.Equal(ReceiptStatus.Failed, Check("Q35", "S3", "nothing-disabled-on-b") + .Evaluate(Evidence(("aa-release-on-b", ReleasedOnB.Replace("NotStarted", "Completed", StringComparison.Ordinal)))).Status); + Assert.Equal(ReceiptStatus.Passed, Check("Q30.a", "S3", "nothing-freed-on-b") + .Evaluate(Evidence(("allocation-release-on-b", AllocationReleasedOnB))).Status); + Assert.Equal(ReceiptStatus.Failed, Check("Q30.a", "S3", "nothing-freed-on-b") + .Evaluate(Evidence(("allocation-release-on-b", AllocationFreedOnB))).Status); + Assert.Equal(ReceiptStatus.Passed, Check("Q26", "S3", "file-as-process-refused").Evaluate(Evidence(("value-scan-unidentified", + """{"ok":true,"created":false,"failure":{"kind":"TargetIdentityUnavailable","hostEffect":"NotStarted"}}"""))).Status); + } + + [Fact] + public void AMarshallingRefusalIsARaisedError() + { + QualificationCheck check = Check("Q21", "S1", "integer-float-2p53-refused"); + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(("integer-float-2p53", "number has no integer representation"), + errors: ["integer-float-2p53"])).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("integer-float-2p53", """{"value":"9007199254740992"}"""))).Status); + } + + [Fact] + public void MissingStepsAreNotExecutedAndErrorsFail() + { + QualificationCheck check = Check("Q33", "S1", "partial-effect"); + + CheckResult missing = check.Evaluate(Evidence()); + CheckResult error = check.Evaluate(Evidence(("batch-partial", "attempt to call a nil value"), errors: ["batch-partial"])); + CheckResult notJson = check.Evaluate(Evidence(("batch-partial", "not json"))); + + Assert.Equal(ReceiptStatus.NotExecuted, missing.Status); + Assert.Contains("not reached", missing.Observation, StringComparison.Ordinal); + Assert.Equal(ReceiptStatus.Failed, error.Status); + Assert.Equal(ReceiptStatus.Failed, notJson.Status); + } + + [Fact] + public void AnOperatorStepTheDriverDidNotSeeDoneKeepsItsCheckNotExecuted() + { + QualificationCheck check = Check("Q16", "S2", "kept-function-dies"); + + CheckResult skipped = check.Evaluate(Evidence(("toggle-disable", "skipped by the operator: Operator: untick it"), + ("kept-function-after-disable", """{"ok":true}"""), notExecuted: ["toggle-disable"])); + CheckResult failedPrompt = check.Evaluate(Evidence(("toggle-disable", "attempt to call a nil value"), + ("kept-function-after-disable", """{"ok":true}"""), errors: ["toggle-disable"])); + CheckResult ran = check.Evaluate(Evidence(("toggle-disable", "disabled"), ("kept-function-after-disable", "dead activation"), + errors: ["kept-function-after-disable"])); + + Assert.Equal(ReceiptStatus.NotExecuted, skipped.Status); + Assert.Contains("toggle-disable was not performed (NotExecuted: skipped by the operator", skipped.Observation, + StringComparison.Ordinal); + Assert.Equal(ReceiptStatus.NotExecuted, failedPrompt.Status); + Assert.Contains("toggle-disable was not performed (Error:", failedPrompt.Observation, StringComparison.Ordinal); + Assert.Equal(ReceiptStatus.Passed, ran.Status); + } + + [Fact] + public void AFailedEnableIsReadOnlyAfterEveryToggleOfItsProtocol() + { + QualificationCheck check = Check("Q06", "S2", "failed-enable-reported"); + const string Reported = """{"ok":true,"plugin":{"active":true},"ledger":["#2 configure fault=Configure reason=Selected","#2 configure.threw InvalidOperationException"]}"""; + (string Step, string Value)[] steps = + [ + ("toggle-disable-for-fault", "disabled"), ("toggle-enable-faulted", "confirmed by the operator"), + ("toggle-enable-after-fault", "enabled"), ("status-after-fault", Reported) + ]; + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(steps)).Status); + Assert.Equal(ReceiptStatus.NotExecuted, check.Evaluate(Evidence([.. steps.Skip(1)])).Status); + Assert.Equal(ReceiptStatus.NotExecuted, + check.Evaluate(Evidence(steps, [], ["toggle-enable-after-fault"], [], null)).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence([.. steps[..3], ("status-after-fault", + Reported.Replace("configure.threw", "configure", StringComparison.Ordinal))])).Status); + } + + [Fact] + public void TheReuseOfAProcessIdIsNeverPassed() + { + Assert.Equal(ReceiptStatus.NotExecuted, Check("Q30.b", "S3", "process-id-reuse").Evaluate(Evidence()).Status); + } + + [Fact] + public void CleanupOrderIsReadFromTheLastDisabledEnableAndNeverGuessed() + { + QualificationCheck order = Check("Q43", "S2", "cleanup-continues-past-fault"); + QualificationCheck aggregated = Check("Q43", "S2", "failures-aggregated"); + string[] faulted = Disabled(1, "ModuleOnDisabling", "fault.disabling.threw InvalidOperationException", true); + string[] clean = Disabled(2, "None", "fault.disabling", false); + + Assert.Equal(ReceiptStatus.Passed, order.Evaluate(Evidence(lifecycle: faulted)).Status); + Assert.Equal(ReceiptStatus.Passed, aggregated.Evaluate(Evidence(lifecycle: faulted)).Status); + Assert.Equal(ReceiptStatus.Failed, order.Evaluate(Evidence(lifecycle: [.. faulted.Where(static line => !line.Contains("first.", StringComparison.Ordinal))])).Status); + + // An earlier faulty disable never stands in for the last one, and only a whole stage counts. + Assert.Equal(ReceiptStatus.Failed, order.Evaluate(Evidence(lifecycle: [.. faulted, .. clean])).Status); + Assert.Equal(ReceiptStatus.Failed, aggregated.Evaluate(Evidence(lifecycle: [.. faulted, .. clean])).Status); + Assert.Equal(ReceiptStatus.Failed, order.Evaluate(Evidence(lifecycle: [.. faulted.Select(static line => + line.Replace("resource.disposed", "resource.disposed.unused", StringComparison.Ordinal))])).Status); + + // An enable that was never disabled (the one that must fail, or the last one when closeCE disables nothing). + string[] enabledOnly = ["ledger\t#3 configure fault=Configure reason=Selected", "ledger\t#3 configure.threw InvalidOperationException"]; + Assert.Equal(ReceiptStatus.Passed, order.Evaluate(Evidence(lifecycle: [.. faulted, .. enabledOnly])).Status); + Assert.Equal(ReceiptStatus.NotExecuted, order.Evaluate(Evidence(lifecycle: ["ledger\t#1 configure fault=None reason=Absent", "ledger\t#1 first.enabled"])).Status); + Assert.Equal(ReceiptStatus.NotExecuted, aggregated.Evaluate(Evidence()).Status); + } + + [Fact] + public void OnlyTheSdkIdentificationLineNamingTheHarnessIdentifiesIt() + { + QualificationCheck check = Check("Q05", "S1", "identification-line"); + const string Line = ScenarioEvaluators.IdentificationPrefix + "sdk.version=2.0.0+325c47b573f8bd39a247f1d0101f110fa36c1696; " + + "sdk.commit=325c47b573f8bd39a247f1d0101f110fa36c1696; sdk.consistent=true; plugin.id=3; " + + "plugin.assembly=CheatEngine.Client.LivePlugin.Qualification 1.0.0.0; host.argument=0"; + + CheckResult identified = check.Evaluate(DebugOutput("[CheatEngine.SDK.Hosting] Information: other\r\n" + Line + "\r\n")); + CheckResult mentioned = check.Evaluate(DebugOutput( + "[CheatEngine.SDK.Hosting] Error: CheatEngine.Client.LivePlugin.Qualification failed to load\n")); + CheckResult otherPlugin = check.Evaluate(DebugOutput(Line.Replace("plugin.assembly=CheatEngine.Client.LivePlugin.Qualification", + "plugin.assembly=CheatEngine.Client.LivePlugin.QualificationX", StringComparison.Ordinal))); + + Assert.Equal(ReceiptStatus.Passed, identified.Status); + Assert.Contains("plugin.assembly=CheatEngine.Client.LivePlugin.Qualification 1.0.0.0", identified.Observation, StringComparison.Ordinal); + Assert.Equal(ReceiptStatus.Failed, mentioned.Status); + Assert.Equal(ReceiptStatus.Failed, otherPlugin.Status); + Assert.Equal(ReceiptStatus.NotExecuted, check.Evaluate(DebugOutput(string.Empty)).Status); + } + + [Fact] + public void TheLoadedClientVersionNeedsBothClientAssemblies() + { + QualificationCheck check = Check("Q40", "S1", "loaded-client-version"); + Dictionary facts = new(StringComparer.Ordinal) + { + [SessionFacts.ClientPackageVersion] = "1.0.0" + }; + static string Status(string assemblies) + { + return """{"ok":true,"plugin":{"active":true},"assemblies":[""" + assemblies + "]}"; + } + + const string Hosting = """{"role":"clientHosting","informationalVersion":"1.0.0+0123abc"}"""; + const string Core = """{"role":"clientCore","informationalVersion":"1.0.0"}"""; + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence([("status", Status(Hosting + "," + Core))], [], [], [], facts)).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence([("status", Status(string.Empty))], [], [], [], facts)).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence([("status", Status(Hosting))], [], [], [], facts)).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence([("status", Status(Hosting + "," + + Core.Replace("1.0.0", "1.0.0-rc.1", StringComparison.Ordinal)))], [], [], [], facts)).Status); + Assert.Equal(ReceiptStatus.NotExecuted, check.Evaluate(Evidence(("status", Status(Hosting + "," + Core)))).Status); + } + + [Fact] + public void RunnerFactsDecideTheBundleAndHygieneChecks() + { + Dictionary facts = new(StringComparer.Ordinal) + { + [SessionFacts.BundleClientAssembliesMatch] = "true", + [SessionFacts.ExpectedBridgeSha256] = "b008", + [SessionFacts.BundleBridgeSha256] = "b008", + [SessionFacts.DebugOutputSensitiveHits] = "1" + }; + + Assert.Equal(ReceiptStatus.Passed, Check("Q40", "S1", "client-assemblies-packed").Evaluate(Evidence(facts: facts)).Status); + Assert.Equal(ReceiptStatus.Passed, Check("Q40", "S1", "bridge-reviewed").Evaluate(Evidence(facts: facts)).Status); + Assert.Equal(ReceiptStatus.Failed, Check("Q46", "S1", "debug-output-clean").Evaluate(Evidence(facts: facts)).Status); + Assert.Equal(ReceiptStatus.NotExecuted, Check("Q45", "S1", "no-module-injected").Evaluate(Evidence(facts: facts)).Status); + } + + [Fact] + public void TheNeighbourIdentityMustHoldEveryBoolean() + { + QualificationCheck check = Check("Q10", "S5a", "neighbour-identity"); + const string Answer = "Sdk1Neighbour=Answering; SdkHostingMajorIs1=True; BridgeMatchesPackage=True; " + + "OwnLoadContextIsNotDefault=True; SdkHostingMajor2LoadedElsewhere=True"; + + Assert.Equal(ReceiptStatus.Passed, check.Evaluate(Evidence(("neighbour-identity", Answer))).Status); + Assert.Equal(ReceiptStatus.Failed, check.Evaluate(Evidence(("neighbour-identity", + Answer.Replace("BridgeMatchesPackage=True", "BridgeMatchesPackage=False", StringComparison.Ordinal)))).Status); + } + + [Fact] + public void AReceiptCarriesTheLevelOfItsScenario() + { + QualificationReceipt receipt = Check("Q09", "S5b", "both-enabled").ToReceipt("20260925T101530Z-a1b2", Evidence( + ("a-identity", "Plugin=A; PluginAssembly=A"), ("b-identity", "Plugin=B; PluginAssembly=B"))); + + Assert.Equal(("S5b", "Q09", "both-enabled", "C4", ReceiptStatus.Passed), + (receipt.Session, receipt.Scenario, receipt.Check, receipt.Level, receipt.Status)); + } + + private static QualificationCheck Check(string scenario, string session, string name) + { + return Assert.Single(ScenarioEvaluators.Checks, + check => check.Scenario == scenario && check.Session == session && check.Name == name); + } + + private static SessionEvidence Evidence(params (string Step, string Value)[] records) + { + return Evidence(records, [], [], [], null); + } + + private static SessionEvidence Evidence((string Step, string Value) first, string[] errors) + { + return Evidence([first], errors, [], [], null); + } + + private static SessionEvidence Evidence((string Step, string Value) first, (string Step, string Value) second, + string[]? errors = null, string[]? notExecuted = null) + { + return Evidence([first, second], errors ?? [], notExecuted ?? [], [], null); + } + + private static SessionEvidence Evidence(string[] lifecycle) + { + return Evidence([], [], [], lifecycle, null); + } + + private static SessionEvidence Evidence(Dictionary facts) + { + return Evidence([], [], [], [], facts); + } + + /// The ledger of one disabled enable: its configured fault, the disable stages and the cleanup log line. + private static string[] Disabled(int enable, string fault, string faultStage, bool aggregated) + { + string prefix = "ledger\t#" + enable.ToString(CultureInfo.InvariantCulture) + " "; + string[] ledger = + [ + prefix + "configure fault=" + fault + " reason=Selected", prefix + "activated", prefix + "plugin.disabling", + prefix + "last.disabling", prefix + "scenario.disabling", prefix + faultStage, prefix + "first.disabling", + prefix + "resource.disposed" + ]; + return aggregated + ? [.. ledger, "log\tWarning\tCheatEngine.Client.Hosting.CheatEngineClientPlugin\t5\tCheat Engine Client activation {Epoch} completed cleanup with {FailureCount} failure(s)."] + : ledger; + } + + private static SessionEvidence DebugOutput(string debugOutput) + { + return new SessionEvidence(TranscriptParser.Parse(Encoding.UTF8.GetBytes("DONE\n")), debugOutput, [], + new Dictionary(StringComparer.Ordinal)); + } + + private static SessionEvidence Evidence((string Step, string Value)[] records, string[] errors, string[] notExecuted, + string[] lifecycle, Dictionary? facts) + { + StringBuilder transcript = new(); + foreach ((string step, string value) in records) + { + string status = errors.Contains(step) ? "error" : notExecuted.Contains(step) ? "notexecuted" : "ok"; + transcript.Append("R\t").Append(step).Append('\t').Append(status).Append('\t') + .Append('"').Append(value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal)) + .Append("\"\n"); + } + + return new SessionEvidence(TranscriptParser.Parse(Encoding.UTF8.GetBytes(transcript.Append("DONE\n").ToString())), + string.Empty, lifecycle, facts ?? new Dictionary(StringComparer.Ordinal)); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/HostProcessGuard.cs b/tests/CheatEngine.Client.Tests/LiveQualification/HostProcessGuard.cs new file mode 100644 index 0000000..771761c --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/HostProcessGuard.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.MemoryMappedFiles; +using System.Runtime.Versioning; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// How a Cheat Engine session process ended. +internal enum HostExit +{ + /// The process exited on its own (the driver called closeCE(), or Cheat Engine stopped). + Exited, + + /// The session timeout elapsed: the runner closed, then killed the process. + TimedOut, + + /// The test was cancelled: the runner closed, then killed the process. + Cancelled +} + +/// +/// The environment a sandboxed Cheat Engine receives: the test host's variables minus every .NET host, MSBuild and +/// test platform setting, and minus any inherited live-probe or Client input, plus the session's own inputs only. +/// +internal static class CheatEngineEnvironment +{ + /// Makes the SDK write its identification line when a plugin is enabled. + internal const string IdentifyOnEnableVariable = "CHEATENGINE_SDK_IDENTIFY_ON_ENABLE"; + + /// Variable name prefixes removed from the inherited environment (ordinal, ignoring case). + internal static readonly string[] RemovedPrefixes = + ["DOTNET_", "MSBUILD", "TESTINGPLATFORM", "VSTEST", "CE_SDK_LIVE_PROBE_", "CECLIENT_QUALIFICATION_", "CHEATENGINE_"]; + + /// The prefixes a session input may use; is added by the runner. + internal static readonly string[] SessionInputPrefixes = ["CE_SDK_LIVE_PROBE_", "CECLIENT_QUALIFICATION_"]; + + /// + /// Removes the inherited variables of from (keeping + /// DOTNET_ROOT* when is set), then adds the session inputs and + /// =1. + /// + internal static void Apply(IDictionary environment, IReadOnlyDictionary sessionInputs, + bool keepDotNetRoot) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(sessionInputs); + + foreach (string name in environment.Keys.ToArray()) + { + bool removed = RemovedPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + bool dotNetRoot = name.StartsWith("DOTNET_ROOT", StringComparison.OrdinalIgnoreCase); + if (removed && !(keepDotNetRoot && dotNetRoot)) + { + environment.Remove(name); + } + } + + foreach ((string name, string value) in sessionInputs) + { + if (!SessionInputPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.Ordinal))) + { + throw new ArgumentException($"'{name}' is not a session input: only {string.Join(", ", SessionInputPrefixes)} variables are.", + nameof(sessionInputs)); + } + + environment[name] = value; + } + + environment[IdentifyOnEnableVariable] = "1"; + } +} + +/// +/// Keeps a live session alone on the workstation: it refuses to start while a Cheat Engine, a gtutorial or a debug +/// output listener (DebugView) runs, starts the sandboxed cheatengine-x86_64.exe directly (never the launcher) +/// with the , and closes, then kills, a session that exceeds its timeout. +/// +[SupportedOSPlatform("windows")] +internal static class HostProcessGuard +{ + /// The longest a session may run. + internal static readonly TimeSpan SessionTimeout = TimeSpan.FromMinutes(10); + + private static readonly TimeSpan CloseGrace = TimeSpan.FromSeconds(10); + + /// + /// Whether a process name belongs to Cheat Engine (cheatengine-x86_64, cheatengine-i386, ...), its + /// Cheat Engine launcher or a gtutorial target. The test host, CheatEngine.Client.Tests, is not one. + /// + internal static bool IsHostOrTargetName(string processName) + { + ArgumentNullException.ThrowIfNull(processName); + return string.Equals(processName, "cheatengine", StringComparison.OrdinalIgnoreCase) || + processName.StartsWith("cheatengine-", StringComparison.OrdinalIgnoreCase) || + processName.StartsWith("gtutorial", StringComparison.OrdinalIgnoreCase) || + string.Equals(processName, "Cheat Engine", StringComparison.OrdinalIgnoreCase); + } + + /// What prevents a session from starting now; empty when the workstation is quiet. + internal static IReadOnlyList FindBlockers() + { + List blockers = []; + foreach (Process process in Process.GetProcesses()) + { + using (process) + { + if (process.Id != Environment.ProcessId && IsHostOrTargetName(process.ProcessName)) + { + blockers.Add(string.Create(CultureInfo.InvariantCulture, $"process '{process.ProcessName}' (id {process.Id}) is running")); + } + } + } + + if (DebugOutputListenerExists()) + { + blockers.Add($"another debug output listener (for example DebugView) owns {DebugOutputCapture.BufferName}"); + } + + return blockers; + } + + /// Whether a debug output listener already owns the DBWIN objects. + internal static bool DebugOutputListenerExists() + { + try + { + using MemoryMappedFile buffer = MemoryMappedFile.OpenExisting(DebugOutputCapture.BufferName, MemoryMappedFileRights.Read); + return true; + } + catch (UnauthorizedAccessException) + { + return true; + } + catch (FileNotFoundException) + { + if (!EventWaitHandle.TryOpenExisting(DebugOutputCapture.BufferReadyName, out EventWaitHandle? ready)) + { + return false; + } + + ready.Dispose(); + return true; + } + } + + /// Starts the sandboxed host executable directly, in its own folder, with the sanitized environment. + internal static Process StartCheatEngine(string executable, IReadOnlyDictionary sessionInputs, + bool keepDotNetRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executable); + ProcessStartInfo startInfo = new(executable) + { + WorkingDirectory = Path.GetDirectoryName(executable)!, + UseShellExecute = false + }; + CheatEngineEnvironment.Apply(startInfo.Environment, sessionInputs, keepDotNetRoot); + return Process.Start(startInfo) ?? throw new InvalidOperationException($"'{executable}' did not start."); + } + + /// Waits for the session to exit; past the timeout or on cancellation, closes, then kills it. + internal static async Task WaitForExitAsync(Process process, TimeSpan timeout, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(process); + using CancellationTokenSource deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + try + { + await process.WaitForExitAsync(deadline.Token); + return HostExit.Exited; + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Stop(process); + return cancellationToken.IsCancellationRequested ? HostExit.Cancelled : HostExit.TimedOut; + } + } + + /// Closes the main window, then kills the process tree if it is still running after a grace period. + internal static void Stop(Process process) + { + ArgumentNullException.ThrowIfNull(process); + try + { + if (process.HasExited) + { + return; + } + + process.CloseMainWindow(); + if (!process.WaitForExit(CloseGrace)) + { + process.Kill(true); + process.WaitForExit(CloseGrace); + } + } + catch (InvalidOperationException) + { + // The process exited between the checks, or was never started. + } + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/HostProcessGuardTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/HostProcessGuardTests.cs new file mode 100644 index 0000000..3823aee --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/HostProcessGuardTests.cs @@ -0,0 +1,100 @@ +using System.Runtime.Versioning; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// What the host guard decides without starting a process: which process names block a session, what the sandboxed +/// Cheat Engine's environment keeps, and which target modules betray an injection (Q45). +/// +[SupportedOSPlatform("windows")] +public sealed class HostProcessGuardTests +{ + [Theory] + [InlineData("cheatengine-x86_64", true)] + [InlineData("CheatEngine-i386", true)] + [InlineData("cheatengine-x86_64-SSE4-AVX2", true)] + [InlineData("Cheat Engine", true)] + [InlineData("gtutorial-x86_64", true)] + [InlineData("gtutorial-i386", true)] + [InlineData("CheatEngine.Client.Tests", false)] + [InlineData("dotnet", false)] + [InlineData("Cheat Engine Helper", false)] + public void HostAndTargetProcessNamesAreRecognized(string name, bool blocks) + { + Assert.Equal(blocks, HostProcessGuard.IsHostOrTargetName(name)); + } + + [Fact] + public void TheSandboxEnvironmentDropsHostBuildTestAndInheritedLiveProbeVariables() + { + Dictionary environment = Inherited(); + + CheatEngineEnvironment.Apply(environment, new Dictionary(StringComparer.Ordinal) + { + ["CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT"] = "phrase", + ["CECLIENT_QUALIFICATION_SESSION"] = "S0" + }, false); + + Assert.Equal( + [ + "CECLIENT_QUALIFICATION_SESSION=S0", + "CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT=phrase", + "CHEATENGINE_SDK_IDENTIFY_ON_ENABLE=1", + "Path=C:\\Windows", + "TEMP=C:\\Temp" + ], environment.Select(static pair => $"{pair.Key}={pair.Value}").Order(StringComparer.Ordinal)); + } + + [Fact] + public void DotNetRootIsKeptOnlyWhenTheSpikeRequiresIt() + { + Dictionary kept = Inherited(); + CheatEngineEnvironment.Apply(kept, new Dictionary(StringComparer.Ordinal), true); + + Assert.Equal("C:\\dotnet", kept["DOTNET_ROOT"]); + Assert.Equal("C:\\dotnet", kept["DOTNET_ROOT_X64"]); + Assert.False(kept.ContainsKey("DOTNET_gcServer")); + } + + [Theory] + [InlineData("CHEATENGINE_CLIENT_LIVE_QUALIFICATION")] + [InlineData("DOTNET_ROOT")] + [InlineData("ce_sdk_live_probe_acknowledgement")] + public void OnlyLiveProbeAndQualificationInputsMayBeAdded(string name) + { + Assert.Throws(() => CheatEngineEnvironment.Apply(Inherited(), + new Dictionary(StringComparer.Ordinal) { [name] = "value" }, false)); + } + + [Fact] + public void InjectedHelperModulesAreFoundInATarget() + { + string[] modules = ["ntdll.dll", "SpeedHack-x86_64.dll", "kernel32.dll", "luaclient-x86_64.dll", "dbk64.sys", "allochook-x86_64.dll", + "vehdebug-x86_64.dll", "gtutorial-x86_64.exe"]; + + Assert.Equal(["allochook-x86_64.dll", "dbk64.sys", "luaclient-x86_64.dll", "speedhack-x86_64.dll", "vehdebug-x86_64.dll"], + TargetLauncher.FindForbiddenModules(modules)); + Assert.Empty(TargetLauncher.FindForbiddenModules(["ntdll.dll", "gtutorial-x86_64.exe", "user32.dll"])); + } + + private static Dictionary Inherited() + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Path"] = "C:\\Windows", + ["TEMP"] = "C:\\Temp", + ["DOTNET_ROOT"] = "C:\\dotnet", + ["DOTNET_ROOT_X64"] = "C:\\dotnet", + ["DOTNET_gcServer"] = "1", + ["MSBuildExtensionsPath"] = "C:\\msbuild", + ["MSBUILDNOINPROCNODE"] = "1", + ["TESTINGPLATFORM_DIAGNOSTIC"] = "1", + ["VSTEST_HOST_DEBUG"] = "1", + ["CHEATENGINE_CLIENT_LIVE_QUALIFICATION"] = "I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET", + ["CHEATENGINE_CLIENT_PACKAGE_SOURCE"] = "C:\\packages", + ["CHEATENGINE_SDK_IDENTIFY_ON_ENABLE"] = "0", + ["CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE"] = "C:\\stale.json", + ["CECLIENT_QUALIFICATION_ENABLE_AA"] = "1" + }; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationFixture.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationFixture.cs new file mode 100644 index 0000000..5f8a620 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationFixture.cs @@ -0,0 +1,81 @@ +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Packaging; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The serial collection of the live qualification tests: one Cheat Engine sandbox at a time, never in parallel with +/// another live fact. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +[SupportedOSPlatform("windows")] +public sealed class LiveQualificationSerialGroup : ICollectionFixture +{ + /// The collection name. + public const string Name = "Live qualification"; +} + +/// +/// Evaluates the opt-in first and does nothing else when it refuses, so an unauthorized run fails in milliseconds +/// with instructions. Only an authorized run initializes the the plugins are +/// built from: the exact packages of CHEATENGINE_CLIENT_PACKAGE_SOURCE, restored in isolated consumers with +/// CheatEngine.SDK from nuget.org. +/// +[SupportedOSPlatform("windows")] +public sealed class LiveQualificationFixture : IAsyncLifetime +{ + private PackagedClientFeedFixture? _feed; + private LiveQualificationRun? _run; + + /// The opt-in decision of this run. + internal LiveQualificationDecision Decision + { + get; + private set; + } = new(null, "The live qualification fixture was not initialized."); + + /// The packed Client feed; only an authorized run has one. + internal PackagedClientFeedFixture Feed => + _feed ?? throw new InvalidOperationException("Only an authorized live qualification run builds the packaged feed."); + + /// + public async ValueTask InitializeAsync() + { + Decision = LiveQualificationOptIn.ResolveFromEnvironment(); + if (!Decision.IsAuthorized) + { + return; + } + + _feed = new PackagedClientFeedFixture(); + await _feed.InitializeAsync(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_feed is not null) + { + await _feed.DisposeAsync(); + } + } + + /// + /// The run the sessions S1 to S6 of this test run share: one run directory, one receipt ledger and one summary. + /// The S0 spike keeps its own run, whose receipts are never committed. + /// + internal LiveQualificationRun Run(LiveQualificationInputs inputs) + { + ArgumentNullException.ThrowIfNull(inputs); + return _run ??= LiveQualificationRun.Create(inputs.RunRoot); + } + + /// Fails the calling test, with the opt-in instructions, unless this run is authorized and its feed is usable. + internal LiveQualificationInputs RequireAuthorization() + { + Assert.True(Decision.IsAuthorized, Decision.Refusal); + Feed.RequirePackages(); + return Decision.Inputs!; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationOptIn.cs new file mode 100644 index 0000000..91656d4 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -0,0 +1,155 @@ +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Infrastructure; +using CheatEngine.Client.Tests.Packaging; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// What an authorized live qualification run consumes. +/// The Cheat Engine installation the sandbox is copied from; never written. +/// The folder that receives one directory per run, outside the repository. +/// The directory of the packed Client packages every plugin is built from. +internal sealed record LiveQualificationInputs(string CheatEngineDirectory, string RunRoot, string PackageSource); + +/// The outcome of : the inputs, or why the run is refused. +internal sealed record LiveQualificationDecision(LiveQualificationInputs? Inputs, string? Refusal) +{ + /// Whether the run is authorized. + internal bool IsAuthorized => Inputs is not null; +} + +/// +/// The opt-in of the live qualification tests. They start a sandboxed Cheat Engine, so they run only on a maintainer +/// workstation that affirms the exact phrase and names the packed Client packages, and never in CI. Without the +/// opt-in they fail with the instructions below; they are never skipped (--fail-skips on), and CI excludes +/// them by trait instead. +/// +[SupportedOSPlatform("windows")] +internal static class LiveQualificationOptIn +{ + /// The opt-in variable. + internal const string OptInVariable = "CHEATENGINE_CLIENT_LIVE_QUALIFICATION"; + + /// The exact phrase the opt-in variable must hold, the same phrase the harness gate requires. + internal const string Acknowledgement = "I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET"; + + /// Optional: the Cheat Engine installation to copy (read only). + internal const string CheatEngineDirectoryVariable = "CHEATENGINE_CLIENT_LIVE_QUALIFICATION_CE_DIRECTORY"; + + /// Optional: the folder that receives the run directories. + internal const string RunRootVariable = "CHEATENGINE_CLIENT_LIVE_QUALIFICATION_RUN_ROOT"; + + /// The default Cheat Engine installation. + internal const string DefaultCheatEngineDirectory = "C:/Program Files/Cheat Engine"; + + /// The default run root, relative to %LOCALAPPDATA%. + internal const string DefaultRunRootBelowLocalApplicationData = "CheatEngine.Client.LiveQualification/runs"; + + /// The commands of a local S0 run, from the repository root, in PowerShell. The README repeats them. + internal static readonly string[] LocalCommand = + [ + "dotnet build CheatEngine.Client.slnx -c Release --no-restore", + "dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget", + "$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path artifacts/nuget).Path", + $"$env:{OptInVariable} = '{Acknowledgement}'", + "dotnet test --project tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj -c Release --no-build --filter-trait Session=S0", + $"Remove-Item Env:{OptInVariable}" + ]; + + /// How to run the live qualification tests, appended to every refusal. + internal static string Instructions + { + get; + } = string.Join(Environment.NewLine, + [ + "Live qualification copies Cheat Engine 7.7.0.10621 x64 into a sandbox and drives it against a disposable gtutorial", + "target. It runs only on a maintainer workstation, never in CI, and only when explicitly authorized:", + " 1. Close Cheat Engine, every gtutorial and DebugView.", + " 2. From the repository root, in PowerShell:", + .. LocalCommand.Select(static line => " " + line), + $" Optional: {CheatEngineDirectoryVariable} (default {DefaultCheatEngineDirectory}, read only) and", + $" {RunRootVariable} (default %LOCALAPPDATA%/{DefaultRunRootBelowLocalApplicationData}, outside the repository).", + " See tests/CheatEngine.Client.Tests/README.md, section 'Live qualification'." + ]); + + /// Reads the process environment. + internal static LiveQualificationDecision ResolveFromEnvironment() + { + return Evaluate(Environment.GetEnvironmentVariable, + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), RepositoryLayout.Root, + Directory.Exists); + } + + /// Decides from explicit inputs, so every refusal is testable without an environment. + internal static LiveQualificationDecision Evaluate(Func variables, string localApplicationData, + string repositoryRoot, Func directoryExists) + { + ArgumentNullException.ThrowIfNull(variables); + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + ArgumentNullException.ThrowIfNull(directoryExists); + + if (string.Equals(variables("CI"), "true", StringComparison.OrdinalIgnoreCase)) + { + return Refuse("CI=true: live qualification never runs in continuous integration. Both CI test legs exclude it " + + "with --filter-not-trait Category=LiveQualification."); + } + + if (!string.Equals(variables(OptInVariable), Acknowledgement, StringComparison.Ordinal)) + { + return Refuse($"{OptInVariable} does not hold the exact phrase {Acknowledgement}, so this run is not authorized."); + } + + string? packageSource = variables(PackageSourceResolution.PackageSourceVariable); + if (string.IsNullOrWhiteSpace(packageSource) || !Path.IsPathFullyQualified(packageSource) || + !directoryExists(packageSource)) + { + return Refuse($"{PackageSourceResolution.PackageSourceVariable} must name the existing absolute directory of the " + + "packed Client packages: every live plugin is built from them, never from the workspace."); + } + + string cheatEngine = variables(CheatEngineDirectoryVariable) is { Length: > 0 } configured + ? configured + : DefaultCheatEngineDirectory; + if (!Path.IsPathFullyQualified(cheatEngine) || !directoryExists(cheatEngine)) + { + return Refuse($"The Cheat Engine installation '{cheatEngine}' ({CheatEngineDirectoryVariable}) is not an existing " + + "absolute directory."); + } + + string? runRoot = variables(RunRootVariable) is { Length: > 0 } root + ? root + : string.IsNullOrWhiteSpace(localApplicationData) + ? null + : Path.Combine(localApplicationData, DefaultRunRootBelowLocalApplicationData); + if (runRoot is null || !Path.IsPathFullyQualified(runRoot)) + { + return Refuse($"The run root '{runRoot}' ({RunRootVariable}) must be an absolute directory."); + } + + string fullCheatEngine = Path.GetFullPath(cheatEngine); + string fullRunRoot = Path.GetFullPath(runRoot); + if (IsSameOrBelow(fullRunRoot, Path.GetFullPath(repositoryRoot)) || IsSameOrBelow(fullRunRoot, fullCheatEngine) || + IsSameOrBelow(fullCheatEngine, fullRunRoot)) + { + return Refuse($"The run root '{fullRunRoot}' must lie outside the repository and apart from the Cheat Engine " + + "installation."); + } + + return new LiveQualificationDecision( + new LiveQualificationInputs(fullCheatEngine, fullRunRoot, Path.GetFullPath(packageSource)), null); + } + + /// Whether is or lies below it. + internal static bool IsSameOrBelow(string path, string directory) + { + string normalizedPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + string normalizedDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + return string.Equals(normalizedPath, normalizedDirectory, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith(normalizedDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static LiveQualificationDecision Refuse(string reason) + { + return new LiveQualificationDecision(null, reason + Environment.NewLine + Environment.NewLine + Instructions); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationOptInTests.cs new file mode 100644 index 0000000..40a51ad --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -0,0 +1,193 @@ +using System.Reflection; +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Infrastructure; +using CheatEngine.Client.Tests.Packaging; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The live qualification opt-in, without starting anything: an unauthorized run fails with instructions (never a +/// skip), CI always refuses, the packed packages are required, and every live fact is serial, traited and unskippable. +/// +[SupportedOSPlatform("windows")] +public sealed class LiveQualificationOptInTests +{ + private const string LocalApplicationData = @"C:\Users\operator\AppData\Local"; + private const string Repository = @"D:\work\CheatEngine.Client"; + private const string Packages = @"D:\work\CheatEngine.Client\artifacts\nuget"; + + [Fact] + public void WithoutTheOptInTheRunFailsWithTheLocalCommand() + { + LiveQualificationDecision decision = Evaluate(new Dictionary(StringComparer.Ordinal) + { + [PackageSourceResolution.PackageSourceVariable] = Packages + }); + + Assert.False(decision.IsAuthorized); + Assert.Null(decision.Inputs); + Assert.Contains(LiveQualificationOptIn.OptInVariable, decision.Refusal, StringComparison.Ordinal); + Assert.All(LiveQualificationOptIn.LocalCommand, line => Assert.Contains(line, decision.Refusal, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("")] + [InlineData("yes")] + [InlineData("i_authorize_ce77_live_probes_on_a_disposable_target")] + [InlineData(" I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET")] + public void OnlyTheExactPhraseIsAnOptIn(string phrase) + { + LiveQualificationDecision decision = Evaluate(Authorized(LiveQualificationOptIn.OptInVariable, phrase)); + + Assert.False(decision.IsAuthorized); + Assert.Contains("exact phrase", decision.Refusal, StringComparison.Ordinal); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + public void ContinuousIntegrationAlwaysRefuses(string value) + { + LiveQualificationDecision decision = Evaluate(Authorized("CI", value)); + + Assert.False(decision.IsAuthorized); + Assert.StartsWith("CI=true", decision.Refusal, StringComparison.Ordinal); + Assert.Contains("--filter-not-trait Category=LiveQualification", decision.Refusal, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + [InlineData("artifacts/nuget")] + [InlineData(@"D:\missing")] + public void ThePackedPackagesAreRequired(string? packageSource) + { + LiveQualificationDecision decision = Evaluate(Authorized(PackageSourceResolution.PackageSourceVariable, packageSource)); + + Assert.False(decision.IsAuthorized); + Assert.Contains(PackageSourceResolution.PackageSourceVariable, decision.Refusal, StringComparison.Ordinal); + } + + [Fact] + public void DefaultsAreTheProgramFilesInstallationAndALocalAppDataRunRoot() + { + LiveQualificationDecision decision = Evaluate(Authorized()); + + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal(Path.GetFullPath(LiveQualificationOptIn.DefaultCheatEngineDirectory), decision.Inputs!.CheatEngineDirectory); + Assert.Equal(Path.GetFullPath(Path.Combine(LocalApplicationData, "CheatEngine.Client.LiveQualification", "runs")), + decision.Inputs.RunRoot); + Assert.Equal(Packages, decision.Inputs.PackageSource); + } + + [Fact] + public void ConfiguredDirectoriesAreUsedWhenValid() + { + Dictionary variables = Authorized(LiveQualificationOptIn.CheatEngineDirectoryVariable, @"E:\CE77"); + variables[LiveQualificationOptIn.RunRootVariable] = @"E:\runs"; + + LiveQualificationDecision decision = Evaluate(variables); + + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal(@"E:\CE77", decision.Inputs!.CheatEngineDirectory); + Assert.Equal(@"E:\runs", decision.Inputs.RunRoot); + } + + [Theory] + [InlineData(LiveQualificationOptIn.RunRootVariable, @"D:\work\CheatEngine.Client\artifacts\runs")] + [InlineData(LiveQualificationOptIn.RunRootVariable, @"C:\Program Files\Cheat Engine\runs")] + [InlineData(LiveQualificationOptIn.RunRootVariable, "runs")] + [InlineData(LiveQualificationOptIn.CheatEngineDirectoryVariable, "Cheat Engine")] + [InlineData(LiveQualificationOptIn.CheatEngineDirectoryVariable, @"E:\missing")] + public void InvalidDirectoriesAreRefused(string variable, string value) + { + LiveQualificationDecision decision = Evaluate(Authorized(variable, value)); + + Assert.False(decision.IsAuthorized); + Assert.Contains(variable == LiveQualificationOptIn.RunRootVariable ? "run root" : "Cheat Engine installation", + decision.Refusal, StringComparison.Ordinal); + } + + [Fact] + public void TheReadmeStatesTheExactLocalCommand() + { + string readme = File.ReadAllText(RepositoryLayout.Combine("tests/CheatEngine.Client.Tests/README.md")); + + Assert.Contains("## Live qualification", readme, StringComparison.Ordinal); + Assert.All(LiveQualificationOptIn.LocalCommand, line => Assert.Contains(line, readme, StringComparison.Ordinal)); + } + + [Fact] + public void LiveFactsAreSerialTraitedAndNeverSkipped() + { + Type[] live = typeof(LiveQualificationOptInTests).Assembly.GetTypes() + .Where(static type => Traits(type).Contains(("Category", "LiveQualification")) || + Collection(type) == LiveQualificationSerialGroup.Name) + .ToArray(); + + Assert.Contains(typeof(LiveSandboxSpikeTests), live); + foreach (Type type in live) + { + List<(string Name, string Value)> traits = Traits(type); + Assert.Equal(LiveQualificationSerialGroup.Name, Collection(type)); + Assert.Contains(("Category", "LiveQualification"), traits); + Assert.Single(traits, static trait => trait.Name == "Session" && + trait.Value is ['S', >= '0' and <= '6']); + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + foreach (FactAttribute fact in method.GetCustomAttributes()) + { + Assert.Null(fact.Skip); + Assert.Null(fact.SkipUnless); + Assert.Null(fact.SkipWhen); + Assert.False(fact.Explicit, $"{type.Name}.{method.Name} is explicit: a live fact fails without the opt-in instead."); + } + } + } + } + + private static LiveQualificationDecision Evaluate(IReadOnlyDictionary variables) + { + HashSet existing = new(StringComparer.OrdinalIgnoreCase) + { + Packages, + Path.GetFullPath(LiveQualificationOptIn.DefaultCheatEngineDirectory), + LiveQualificationOptIn.DefaultCheatEngineDirectory, + @"E:\CE77" + }; + return LiveQualificationOptIn.Evaluate(name => variables.GetValueOrDefault(name), LocalApplicationData, Repository, + existing.Contains); + } + + private static Dictionary Authorized(string? variable = null, string? value = null) + { + Dictionary variables = new(StringComparer.Ordinal) + { + [LiveQualificationOptIn.OptInVariable] = LiveQualificationOptIn.Acknowledgement, + [PackageSourceResolution.PackageSourceVariable] = Packages + }; + if (variable is not null) + { + variables[variable] = value; + } + + return variables; + } + + private static List<(string Name, string Value)> Traits(Type type) + { + return type.GetCustomAttributesData() + .Where(static attribute => attribute.AttributeType == typeof(TraitAttribute)) + .Select(static attribute => ((string) attribute.ConstructorArguments[0].Value!, (string) attribute.ConstructorArguments[1].Value!)) + .ToList(); + } + + private static string? Collection(Type type) + { + return type.GetCustomAttributesData() + .Where(static attribute => attribute.AttributeType == typeof(CollectionAttribute)) + .Select(static attribute => attribute.ConstructorArguments[0].Value as string) + .FirstOrDefault(); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSession.Sessions.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSession.Sessions.cs new file mode 100644 index 0000000..910e5bc --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSession.Sessions.cs @@ -0,0 +1,432 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +using CheatEngine.Client.Tests.Infrastructure; +using CheatEngine.Client.Tests.Packaging; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// One live qualification run: the run directory every session of the collection shares, its receipt ledger and its +/// summary, rewritten after each session so that an interrupted run still leaves what it recorded. +/// +[SupportedOSPlatform("windows")] +internal sealed class LiveQualificationRun +{ + private readonly List _receipts = []; + private readonly List _sessions = []; + private readonly List _targets = []; + private bool _registryRestored = true; + private QualificationTuple? _tuple; + + private LiveQualificationRun(SandboxLayout layout) + { + Layout = layout; + Redaction = QualificationRedaction.ForWorkstation(layout.RunDirectory); + Ledger = new ReceiptLedger(layout.ReceiptsPath, Redaction); + } + + /// The run directory. + internal SandboxLayout Layout + { + get; + } + + /// The redaction of everything the run records. + internal QualificationRedaction Redaction + { + get; + } + + /// The receipt ledger of the run. + internal ReceiptLedger Ledger + { + get; + } + + /// Creates the run directory below . + internal static LiveQualificationRun Create(string runRoot) + { + return new LiveQualificationRun(SandboxLayout.Create(runRoot, DateTimeOffset.UtcNow)); + } + + /// Records one session: its receipts go to the ledger, and the summary is rewritten with every session so far. + internal IReadOnlyList Record(SessionSummary session, IEnumerable receipts, + bool registryRestored, QualificationTuple tuple) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(receipts); + ArgumentNullException.ThrowIfNull(tuple); + List recorded = [.. receipts.Select(Ledger.Append)]; + _receipts.AddRange(recorded); + _sessions.Add(session); + _registryRestored &= registryRestored; + _targets.AddRange(tuple.Targets.Where(target => !_targets.Contains(target))); + _tuple = (_tuple ?? tuple) with + { + Targets = [.. _targets] + }; + QualificationSummaryWriter.Write(Layout.SummaryPath, + new QualificationSummary(Layout.RunId, _tuple, [.. _sessions], [.. _receipts], ScenarioCatalog.CapabilityScenarios, + _registryRestored), Redaction); + return recorded; + } +} + +/// The sessions S1 to S6 of the live runner (runner specification, plan L24). +internal static partial class LiveSandboxSession +{ + /// The sensitive-value marker every harness Auto Assembler script contains (the harness log sink's marker). + internal const string ScriptMarker = "cheatengine_client_qualification_script"; + + /// + /// Runs one session of : the same guarded order as the spike, with the session's targets, + /// bundles, harness inputs and driver, then the evaluators of its checks. The receipts are recorded in + /// . + /// + [SupportedOSPlatform("windows")] + internal static async Task RunSessionAsync(QualificationSessionPlan plan, LiveQualificationInputs inputs, + PackagedClientFeedFixture feed, ICheatEngineUserStateGuard userState, LiveQualificationRun run, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(feed); + ArgumentNullException.ThrowIfNull(userState); + ArgumentNullException.ThrowIfNull(run); + + IReadOnlyList blockers = HostProcessGuard.FindBlockers(); + Assert.True(blockers.Count == 0, $"A live session needs a quiet workstation: {string.Join("; ", blockers)}."); + CheatEngineProfile profile = CheatEngineProfile.CheatEngine77; + IReadOnlyList problems = CheatEngineInstallation.Verify(inputs.CheatEngineDirectory, profile, PortableExecutableInspector.Instance); + Assert.True(problems.Count == 0, $"The Cheat Engine installation is not the qualified profile: {string.Join(" ", problems)}"); + InstallationFingerprint before = CheatEngineInstallation.Fingerprint(inputs.CheatEngineDirectory, profile); + + SandboxLayout layout = run.Layout; + string sessionDirectory = layout.SessionDirectory(plan.Session); + string transcriptPath = Path.Combine(sessionDirectory, "transcript.txt"); + string debugOutputPath = Path.Combine(sessionDirectory, "debug-output.txt"); + string lifecyclePath = Path.Combine(sessionDirectory, "lifecycle.txt"); + Dictionary facts = new(StringComparer.Ordinal) + { + [SessionFacts.ClientPackageVersion] = feed.ClientVersion + }; + if (feed.UsesPinnedSdk) + { + facts[SessionFacts.ExpectedBridgeSha256] = feed.ConsumedSdk.GetProperty("nativeBridge").GetProperty("sha256").GetString() ?? string.Empty; + facts[SessionFacts.ExpectedSdkContentHash] = feed.ConsumedSdk.GetProperty("contentHashSha512").GetString() ?? string.Empty; + } + + HostExit exit = HostExit.Exited; + List sensitive = [ScriptMarker]; + List targetIdentities = []; + Dictionary bundles = []; + bool restored; + using (ICheatEngineUserStateScope scope = userState.Begin(layout)) + { + if (Directory.Exists(layout.CheatEngineDirectory)) + { + Directory.Delete(layout.CheatEngineDirectory, true); + } + + CheatEngineInstallation.CopyTo(inputs.CheatEngineDirectory, layout.CheatEngineDirectory, profile, PortableExecutableInspector.Instance); + TemplateBundle? template = await BuildBundlesAsync(plan.Setup, feed, Path.Combine(sessionDirectory, "plugins"), bundles); + RecordBundleFacts(feed, bundles, template, facts); + + Dictionary targets = new(StringComparer.Ordinal); + Process? host = null; + DebugOutputCapture? capture = null; + try + { + foreach (SessionTarget target in plan.Setup.Targets) + { + targets[target.Role] = TargetLauncher.Start(Path.Combine(layout.CheatEngineDirectory, target.Executable), + profile.TargetSha256[target.Executable]); + targetIdentities.Add(new TargetIdentity(target.Executable, targets[target.Role].ImageSha256)); + } + + SessionContext context = CreateContext(plan, layout, sessionDirectory, transcriptPath, targets, bundles, template); + sensitive.AddRange([context.ModuleHeaderPattern, context.AbsentPattern, + context.FirstMarker.ToString(CultureInfo.InvariantCulture), context.NextMarker.ToString(CultureInfo.InvariantCulture)]); + Dictionary sessionInputs = SessionInputs(plan, sessionDirectory, lifecyclePath, profile, targets, bundles); + await File.WriteAllTextAsync(layout.DriverScriptPath, LuaDriverScript.RenderSteps(plan.Session, transcriptPath, + plan.Driver(context)), new UTF8Encoding(false), cancellationToken); + + capture = DebugOutputCapture.Start(DebugOutputBuffer.SystemAnsiEncoding()); + host = HostProcessGuard.StartCheatEngine(Path.Combine(layout.CheatEngineDirectory, profile.HostExecutable), + sessionInputs, false); + capture.Buffer.Track(host.Id); + exit = await HostProcessGuard.WaitForExitAsync(host, HostProcessGuard.SessionTimeout, cancellationToken); + RecordModuleFacts(targets.Values, facts); + } + finally + { + if (host is not null) + { + HostProcessGuard.Stop(host); + capture?.WriteTo(debugOutputPath, host.Id); + host.Dispose(); + } + + capture?.Dispose(); + foreach (LaunchedTarget target in targets.Values) + { + target.Dispose(); + } + + scope.Restore(); + } + + restored = scope.Restored; + } + + IReadOnlyList changes = CheatEngineInstallation.Compare(before, CheatEngineInstallation.Fingerprint(inputs.CheatEngineDirectory, profile)); + IReadOnlyList leftovers = HostProcessGuard.FindBlockers(); + Transcript transcript = TranscriptParser.ParseFile(transcriptPath); + string debugOutput = File.Exists(debugOutputPath) ? await File.ReadAllTextAsync(debugOutputPath, cancellationToken) : string.Empty; + if (Observed.TryParse(transcript.Find("target-declare")?.Value ?? string.Empty, out Observed? declared) && + declared.Text("scratch") is { Length: > 2 } scratch) + { + sensitive.Add(scratch[2..]); + } + + facts[SessionFacts.DebugOutputSensitiveHits] = CountValues(debugOutput, sensitive).ToString(CultureInfo.InvariantCulture); + IReadOnlyList lifecycle = File.Exists(lifecyclePath) ? await File.ReadAllLinesAsync(lifecyclePath, cancellationToken) : []; + SessionEvidence evidence = new(transcript, debugOutput, lifecycle, facts); + + SessionOutcome outcome = exit switch + { + HostExit.TimedOut => SessionOutcome.TimedOut, + HostExit.Exited when transcript.Completed => SessionOutcome.Completed, + _ => SessionOutcome.Failed + }; + string reason = outcome == SessionOutcome.Completed ? string.Empty : $"host {exit}, transcript completed: {transcript.Completed}"; + List receipts = + [ + .. SessionHygieneReceipts(run.Layout.RunId, plan.Session, transcript, outcome, restored, changes, leftovers), + .. plan.Checks.Select(check => check.ToReceipt(run.Layout.RunId, evidence)) + ]; + PluginBundle tupleBundle = bundles.GetValueOrDefault(SessionBundle.Harness) ?? bundles.Values.First(); + QualificationTuple tuple = Tuple(feed, profile, tupleBundle, layout, targetIdentities); + IReadOnlyList recorded = run.Record(new SessionSummary(plan.Session, outcome, reason), receipts, restored, tuple); + return new LiveSessionResult(layout, outcome, reason, transcript, recorded, restored, changes, leftovers); + } + + /// The hygiene checks every session records under its own id: it ran to its end and left the workstation as it was. + internal static IEnumerable SessionHygieneReceipts(string runId, string session, Transcript transcript, + SessionOutcome outcome, bool restored, IReadOnlyList changes, IReadOnlyList leftovers) + { + ArgumentNullException.ThrowIfNull(transcript); + yield return Receipt("session-completed", "the driver writes DONE and Cheat Engine exits after closeCE()", + outcome == SessionOutcome.Completed, $"outcome {outcome}; {transcript.Records.Count} records; problems: {string.Join("; ", transcript.Problems)}"); + yield return Receipt("user-state-restored", "HKCU\\Software\\Cheat Engine and %APPDATA%\\Cheat Engine equal their backup", + restored, restored ? "restored and verified" : "not restored"); + yield return Receipt("installation-unchanged", "the source installation's host executable and autorun folder are unchanged", + changes.Count == 0, changes.Count == 0 ? "unchanged" : string.Join("; ", changes)); + yield return Receipt("no-process-left", "no Cheat Engine or gtutorial process survives the session", leftovers.Count == 0, + leftovers.Count == 0 ? "none" : string.Join("; ", leftovers)); + + QualificationReceipt Receipt(string check, string expectation, bool passed, string observation) + { + return new QualificationReceipt(runId, session, session, check, "C3", + passed ? ReceiptStatus.Passed : ReceiptStatus.Failed, expectation, observation); + } + } + + /// How many times the values occur in , ignoring case. + internal static int CountValues(string text, IEnumerable values) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(values); + int count = 0; + foreach (string value in values.Where(static value => value.Trim().Length >= 3).Distinct(StringComparer.OrdinalIgnoreCase)) + { + for (int index = text.IndexOf(value, StringComparison.OrdinalIgnoreCase); index >= 0; + index = text.IndexOf(value, index + value.Length, StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + + return count; + } + + /// The first 8 bytes of a file, as an AOB pattern: the module header of a mapped image (Q27, Q28). + internal static string HeaderPattern(string path) + { + byte[] header = new byte[8]; + using FileStream stream = File.OpenRead(path); + stream.ReadExactly(header); + return Convert.ToHexString(header).Chunk(2).Aggregate(new StringBuilder(), + static (pattern, pair) => pattern.Append(pattern.Length > 0 ? " " : string.Empty).Append(pair)).ToString(); + } + + /// A random 16-byte AOB pattern, which the scanned modules almost surely do not contain (Q27, Q28). + internal static string RandomPattern() + { + return string.Join(' ', RandomNumberGenerator.GetBytes(16).Select(static value => value.ToString("X2", CultureInfo.InvariantCulture))); + } + + [SupportedOSPlatform("windows")] + private static async Task BuildBundlesAsync(SessionSetup setup, PackagedClientFeedFixture feed, + string pluginsDirectory, Dictionary bundles) + { + PluginBundleBuilder builder = new(feed, pluginsDirectory); + TemplateBundle? template = null; + foreach (SessionBundle bundle in setup.Bundles) + { + switch (bundle) + { + case SessionBundle.Harness: + bundles[bundle] = await builder.BuildHarnessAsync(); + break; + case SessionBundle.PluginA or SessionBundle.PluginB or SessionBundle.PluginCollision: + bundles[bundle] = await builder.BuildCoexistenceAsync(bundle); + break; + case SessionBundle.SdkNeighbour: + bundles[bundle] = await builder.BuildNeighbourAsync(); + break; + case SessionBundle.Template: + template = await builder.BuildTemplateAsync(); + bundles[bundle] = template.Bundle; + break; + default: + throw new ArgumentOutOfRangeException(nameof(setup), bundle, "Unknown bundle."); + } + } + + return template; + } + + /// Q40: the Client assemblies of the harness bundle against the packed packages, and the deployed bridge. + private static void RecordBundleFacts(PackagedClientFeedFixture feed, Dictionary bundles, + TemplateBundle? template, Dictionary facts) + { + if (bundles.TryGetValue(SessionBundle.Harness, out PluginBundle? harness)) + { + List mismatches = []; + foreach (string assembly in Directory.EnumerateFiles(harness.Directory, "CheatEngine.Client*.dll")) + { + string name = Path.GetFileName(assembly); + if (string.Equals(name, PluginBundleBuilder.HarnessAssemblyName + ".dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + PackageArchive? archive = feed.Archives.FirstOrDefault(archive => !archive.IsSymbolPackage && archive.Contains("lib/net10.0/" + name)); + if (archive is null || !archive.Entry("lib/net10.0/" + name).AsSpan().SequenceEqual(File.ReadAllBytes(assembly))) + { + mismatches.Add(name); + } + } + + facts[SessionFacts.BundleClientAssembliesMatch] = mismatches.Count == 0 ? "true" : "false: " + string.Join(", ", mismatches); + facts[SessionFacts.BundleBridgeSha256] = harness.BridgeSha256; + } + + if (template is not null) + { + facts[SessionFacts.TemplateSdkContentHash] = template.SdkContentHash ?? "none"; + facts[SessionFacts.TemplateDepsWorkspacePaths] = template.DepsWorkspacePaths.ToString(CultureInfo.InvariantCulture); + } + } + + [SupportedOSPlatform("windows")] + private static void RecordModuleFacts(IEnumerable targets, Dictionary facts) + { + List forbidden = []; + List added = []; + foreach (LaunchedTarget target in targets.Where(static target => target.IsRunning)) + { + IReadOnlyList now = target.SnapshotModules(); + forbidden.AddRange(TargetLauncher.FindForbiddenModules(now)); + added.AddRange(now.Except(target.ModulesAtStart, StringComparer.Ordinal)); + } + + facts[SessionFacts.ForbiddenModules] = string.Join(", ", forbidden.Distinct(StringComparer.Ordinal)); + facts[SessionFacts.ModulesUnchanged] = added.Count == 0 ? "true" : "false: " + string.Join(", ", added.Distinct(StringComparer.Ordinal)); + } + + [SupportedOSPlatform("windows")] + private static SessionContext CreateContext(QualificationSessionPlan plan, SandboxLayout layout, string sessionDirectory, + string transcriptPath, Dictionary targets, Dictionary bundles, + TemplateBundle? template) + { + string module = plan.Setup.Targets.Count > 0 ? plan.Setup.Targets[0].Executable : string.Empty; + string? fileAsProcess = null; + if (plan.Setup.FileAsProcessCopy) + { + string folder = Directory.CreateDirectory(Path.Combine(sessionDirectory, "file-as-process")).FullName; + fileAsProcess = Path.Combine(folder, CheatEngineProfile.Target64); + File.Copy(Path.Combine(layout.CheatEngineDirectory, CheatEngineProfile.Target64), fileAsProcess); + } + + return new SessionContext(transcriptPath, + targets.ToDictionary(static pair => pair.Key, static pair => pair.Value.ProcessId, StringComparer.Ordinal), + bundles.ToDictionary(static pair => pair.Key, static pair => pair.Value.EntryAssemblyPath), + module, + module.Length == 0 ? string.Empty : HeaderPattern(Path.Combine(layout.CheatEngineDirectory, module)), + RandomPattern(), + RandomNumberGenerator.GetInt32(0x1000_0000, int.MaxValue), + RandomNumberGenerator.GetInt32(0x1000_0000, int.MaxValue), + fileAsProcess, + template?.StatusGlobal ?? string.Empty); + } + + [SupportedOSPlatform("windows")] + private static Dictionary SessionInputs(QualificationSessionPlan plan, string sessionDirectory, + string lifecyclePath, CheatEngineProfile profile, Dictionary targets, + Dictionary bundles) + { + Dictionary inputs = new(StringComparer.Ordinal); + if (plan.Setup.AuthorizedRole is { } role) + { + LaunchedTarget authorized = targets[role]; + string manifest = AuthorizationManifestWriter.Write(Path.Combine(sessionDirectory, "authorization.json"), + profile.HostSha256, authorized.ProcessId, authorized.ImageSha256, DateTimeOffset.UtcNow, + AuthorizationManifestWriter.MaximumLifetime); + foreach ((string name, string value) in AuthorizationManifestWriter.SessionInputs(manifest)) + { + inputs[name] = value; + } + } + + if (bundles.TryGetValue(SessionBundle.Harness, out PluginBundle? harness) && plan.Setup.Fault != FaultStage.None) + { + AuthorizationManifestWriter.WriteFaultSwitch(harness.Directory, plan.Setup.Fault); + } + + if (plan.Setup.EnableAutoAssembler) + { + inputs[QualificationInputs.EnableAutoAssemblerVariable] = "1"; + } + + if (plan.Setup.TableRoot) + { + inputs[QualificationInputs.TableRootVariable] = Directory.CreateDirectory(Path.Combine(sessionDirectory, "tables")).FullName; + } + + if (plan.Setup.LifecycleSink) + { + inputs[QualificationInputs.LifecycleFileVariable] = lifecyclePath; + } + + return inputs; + } + + private static QualificationTuple Tuple(PackagedClientFeedFixture feed, CheatEngineProfile profile, PluginBundle bundle, + SandboxLayout layout, List targets) + { + QualificationTuple single = Tuple(feed, profile, bundle, layout, targets.Count > 0 ? targets[0].Sha256 : "none"); + return single with + { + Targets = [.. targets.Distinct()] + }; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSession.cs new file mode 100644 index 0000000..3b90210 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSession.cs @@ -0,0 +1,256 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +using CheatEngine.Client.Tests.Infrastructure; +using CheatEngine.Client.Tests.Packaging; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// What one sandboxed session produced. +/// The run directory. +/// How the session ended. +/// Why, when it did not complete. +/// The driver transcript. +/// The receipts recorded in the ledger. +/// Whether the Cheat Engine user state was restored and verified. +/// How the source installation changed; empty when it is untouched. +/// Cheat Engine or gtutorial processes still running after the session. +internal sealed record LiveSessionResult( + SandboxLayout Layout, + SessionOutcome Outcome, + string Reason, + Transcript Transcript, + IReadOnlyList Receipts, + bool UserStateRestored, + IReadOnlyList InstallationChanges, + IReadOnlyList LeftoverProcesses); + +/// +/// Runs one sandboxed session end to end, in the order of the runner specification: refuse a busy workstation, verify +/// the source installation read-only, back up the user state, copy the installation into the run's sandbox, build the +/// plugin bundle from the packed packages, start the disposable target, write the authorization manifest and the autorun driver, +/// listen to debug output, start the sandboxed Cheat Engine directly and wait for it. A finally always stops +/// Cheat Engine and the target and restores the user state; the source installation is then fingerprinted again. +/// +[SupportedOSPlatform("windows")] +internal static partial class LiveSandboxSession +{ + /// The qualification harness's display name (QualificationPlugin.DisplayName). + internal const string HarnessDisplayName = "CheatEngine.Client Qualification Plugin"; + + /// + /// The S0 checks that must pass; the settings probe and the operator toggles may stay NotExecuted (a toggle passes + /// when the driver saw the plugin's functions disappear, then come back). + /// + internal static readonly string[] SpikeRequiredChecks = + [ + "session-completed", "load-plugin", "harness-ready", "status", "runtime", "capabilities", "user-state-restored", + "installation-unchanged", "no-process-left", "no-injected-module" + ]; + + /// Runs the S0 spike: load the harness on gtutorial-x86_64, call status, runtime and capabilities(1), close. + internal static async Task RunSpikeAsync(LiveQualificationInputs inputs, PackagedClientFeedFixture feed, + ICheatEngineUserStateGuard userState, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(feed); + ArgumentNullException.ThrowIfNull(userState); + + const string session = "S0"; + IReadOnlyList blockers = HostProcessGuard.FindBlockers(); + Assert.True(blockers.Count == 0, $"A live session needs a quiet workstation: {string.Join("; ", blockers)}."); + + CheatEngineProfile profile = CheatEngineProfile.CheatEngine77; + IReadOnlyList problems = CheatEngineInstallation.Verify(inputs.CheatEngineDirectory, profile, PortableExecutableInspector.Instance); + Assert.True(problems.Count == 0, $"The Cheat Engine installation is not the qualified profile: {string.Join(" ", problems)}"); + InstallationFingerprint before = CheatEngineInstallation.Fingerprint(inputs.CheatEngineDirectory, profile); + + SandboxLayout layout = SandboxLayout.Create(inputs.RunRoot, DateTimeOffset.UtcNow); + string sessionDirectory = layout.SessionDirectory(session); + string transcriptPath = Path.Combine(sessionDirectory, "transcript.txt"); + QualificationRedaction redaction = QualificationRedaction.ForWorkstation(layout.RunDirectory); + ReceiptLedger ledger = new(layout.ReceiptsPath, redaction); + + HostExit exit = HostExit.Exited; + IReadOnlyList forbiddenModules = []; + PluginBundle bundle; + string targetSha256; + bool restored; + using (ICheatEngineUserStateScope scope = userState.Begin(layout)) + { + CheatEngineInstallation.CopyTo(inputs.CheatEngineDirectory, layout.CheatEngineDirectory, profile, PortableExecutableInspector.Instance); + bundle = await new PluginBundleBuilder(feed, layout.PluginsDirectory).BuildHarnessAsync(); + LaunchedTarget? target = null; + Process? host = null; + DebugOutputCapture? capture = null; + try + { + string targetPath = Path.Combine(layout.CheatEngineDirectory, CheatEngineProfile.Target64); + target = TargetLauncher.Start(targetPath, profile.TargetSha256[CheatEngineProfile.Target64]); + targetSha256 = target.ImageSha256; + string manifest = AuthorizationManifestWriter.Write(Path.Combine(sessionDirectory, "authorization.json"), + profile.HostSha256, target.ProcessId, target.ImageSha256, DateTimeOffset.UtcNow, + AuthorizationManifestWriter.MaximumLifetime); + LuaDriverPlan plan = LuaDriverScript.SpikePlan(transcriptPath, target.ProcessId, bundle.EntryAssemblyPath, HarnessDisplayName); + await File.WriteAllTextAsync(layout.DriverScriptPath, LuaDriverScript.Render(plan), new UTF8Encoding(false), cancellationToken); + + capture = DebugOutputCapture.Start(DebugOutputBuffer.SystemAnsiEncoding()); + host = HostProcessGuard.StartCheatEngine(Path.Combine(layout.CheatEngineDirectory, profile.HostExecutable), + AuthorizationManifestWriter.SessionInputs(manifest), false); + capture.Buffer.Track(host.Id); + exit = await HostProcessGuard.WaitForExitAsync(host, HostProcessGuard.SessionTimeout, cancellationToken); + forbiddenModules = target.IsRunning ? TargetLauncher.FindForbiddenModules(target.SnapshotModules()) : []; + } + finally + { + if (host is not null) + { + HostProcessGuard.Stop(host); + capture?.WriteTo(Path.Combine(sessionDirectory, "debug-output.txt"), host.Id); + host.Dispose(); + } + + capture?.Dispose(); + target?.Dispose(); + scope.Restore(); + } + + restored = scope.Restored; + } + + IReadOnlyList changes = CheatEngineInstallation.Compare(before, + CheatEngineInstallation.Fingerprint(inputs.CheatEngineDirectory, profile)); + IReadOnlyList leftovers = HostProcessGuard.FindBlockers(); + Transcript transcript = TranscriptParser.ParseFile(transcriptPath); + SessionOutcome outcome = exit switch + { + HostExit.TimedOut => SessionOutcome.TimedOut, + HostExit.Exited when transcript.Completed => SessionOutcome.Completed, + _ => SessionOutcome.Failed + }; + string reason = outcome == SessionOutcome.Completed ? string.Empty : $"host {exit}, transcript completed: {transcript.Completed}"; + + List receipts = []; + foreach (QualificationReceipt receipt in SpikeReceipts(layout.RunId, session, transcript, outcome, restored, changes, leftovers, forbiddenModules)) + { + receipts.Add(ledger.Append(receipt)); + } + + QualificationTuple tuple = Tuple(feed, profile, bundle, layout, targetSha256); + QualificationSummaryWriter.Write(layout.SummaryPath, + new QualificationSummary(layout.RunId, tuple, [new SessionSummary(session, outcome, reason)], receipts, + new Dictionary>(StringComparer.Ordinal), restored), + redaction); + return new LiveSessionResult(layout, outcome, reason, transcript, receipts, restored, changes, leftovers); + } + + /// The S0 checks: the session ran to its end, the harness answered, and the workstation is left as it was. + internal static IEnumerable SpikeReceipts(string runId, string session, Transcript transcript, + SessionOutcome outcome, bool restored, IReadOnlyList changes, IReadOnlyList leftovers, + IReadOnlyList forbiddenModules) + { + ArgumentNullException.ThrowIfNull(transcript); + yield return Receipt("session-completed", "the driver writes DONE and Cheat Engine exits after closeCE()", + outcome == SessionOutcome.Completed, $"outcome {outcome}; {transcript.Records.Count} records; problems: {string.Join("; ", transcript.Problems)}"); + foreach (string step in (string[]) ["load-plugin", "harness-ready"]) + { + TranscriptRecord? record = transcript.Find(step); + yield return Receipt(step, $"the driver step {step} succeeds", record?.Status == TranscriptStatus.Ok, + record is null ? "not reached" : $"{record.Status}: {record.Value}"); + } + + foreach (string step in (string[]) ["status", "runtime", "capabilities"]) + { + TranscriptRecord? record = transcript.Find(step); + yield return Receipt(step, $"the harness answers {step} with a JSON observation", + record?.Status == TranscriptStatus.Ok && IsJsonObject(record.Value), record is null ? "not reached" : $"{record.Status}: {record.Value}"); + } + + // The probe only informs the spike: a settings form it cannot read is a fact to record, not a failure. + TranscriptRecord? probe = transcript.Find("settings-probe"); + yield return new QualificationReceipt(runId, session, session, "settings-probe", "C3", + probe?.Status == TranscriptStatus.Ok ? ReceiptStatus.Passed : ReceiptStatus.NotExecuted, + "the settings form is inspected read-only", probe is null ? "not reached" : $"{probe.Status}: {probe.Value}"); + foreach (string step in (string[]) ["toggle-disable", "toggle-enable"]) + { + TranscriptRecord? record = transcript.Find(step); + yield return new QualificationReceipt(runId, session, session, step, "C3", + record?.Status == TranscriptStatus.Ok ? ReceiptStatus.Passed : ReceiptStatus.NotExecuted, + "the operator toggles the plugin through Settings > Plugins and the driver sees its functions follow", + record?.Value ?? "not reached"); + } + + yield return Receipt("user-state-restored", "HKCU\\Software\\Cheat Engine and %APPDATA%\\Cheat Engine equal their backup", + restored, restored ? "restored and verified" : "not restored"); + yield return Receipt("installation-unchanged", "the source installation's host executable and autorun folder are unchanged", + changes.Count == 0, changes.Count == 0 ? "unchanged" : string.Join("; ", changes)); + yield return Receipt("no-process-left", "no Cheat Engine or gtutorial process survives the session", leftovers.Count == 0, + leftovers.Count == 0 ? "none" : string.Join("; ", leftovers)); + yield return Receipt("no-injected-module", "the target loads no speedhack, allochook, luaclient, vehdebug or dbk module", + forbiddenModules.Count == 0, forbiddenModules.Count == 0 ? "none" : string.Join(", ", forbiddenModules)); + + QualificationReceipt Receipt(string check, string expectation, bool passed, string observation) + { + return new QualificationReceipt(runId, session, session, check, "C3", + passed ? ReceiptStatus.Passed : ReceiptStatus.Failed, expectation, observation); + } + } + + private static bool IsJsonObject(string text) + { + try + { + using JsonDocument document = JsonDocument.Parse(text); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static QualificationTuple Tuple(PackagedClientFeedFixture feed, CheatEngineProfile profile, PluginBundle bundle, + SandboxLayout layout, string targetSha256) + { + PackageArchive client = feed.Package(PackagedClientFeedFixture.ClientPackageId); + string sdkFolder = Path.Combine(feed.PackageCache, "cheatengine.sdk", feed.SdkVersion.ToLowerInvariant()); + string sdkCommit = XDocument.Load(Path.Combine(sdkFolder, "cheatengine.sdk.nuspec")).Descendants() + .FirstOrDefault(static element => element.Name.LocalName == "repository")?.Attribute("commit")?.Value ?? "unknown"; + string sdkContentHash; + using (JsonDocument metadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(sdkFolder, ".nupkg.metadata")))) + { + sdkContentHash = metadata.RootElement.GetProperty("contentHash").GetString() ?? "unknown"; + } + + return new QualificationTuple( + [.. feed.Archives.Where(static archive => !archive.IsSymbolPackage).Select(static archive => new PackageIdentity(archive.Id, archive.Version, archive.Sha256))], + client.MetadataElement("repository")?.Attribute("commit")?.Value ?? "unknown", + QualifiedSourceDigest.Compute(RepositoryLayout.Root), + feed.SdkVersion, sdkCommit, sdkContentHash, bundle.BridgeSha256, profile.Profile, profile.HostFileVersion, + profile.HostSha256, [new TargetIdentity(CheatEngineProfile.Target64, targetSha256)], + ConfiguredRuntime(layout.CheatEngineDirectory), + Environment.OSVersion.Version.ToString()); + } + + /// The framework the sandboxed host's ce.runtimeconfig.json requests. + private static string ConfiguredRuntime(string cheatEngineDirectory) + { + string path = Path.Combine(cheatEngineDirectory, "ce.runtimeconfig.json"); + if (!File.Exists(path)) + { + return "no ce.runtimeconfig.json"; + } + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement options = document.RootElement.GetProperty("runtimeOptions"); + JsonElement framework = options.TryGetProperty("framework", out JsonElement single) + ? single + : options.GetProperty("frameworks")[0]; + return string.Create(CultureInfo.InvariantCulture, + $"{framework.GetProperty("name").GetString()} {framework.GetProperty("version").GetString()} (ce.runtimeconfig.json)"); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSessionTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSessionTests.cs new file mode 100644 index 0000000..bafbf4d --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSessionTests.cs @@ -0,0 +1,73 @@ +using System.Runtime.Versioning; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// How the S0 session turns a transcript and the workstation checks into receipts, without starting anything: a +/// step that never ran fails, an operator toggle passes only when the driver saw it done, and the host hygiene checks +/// are explicit. +/// +[SupportedOSPlatform("windows")] +public sealed class LiveSandboxSessionTests +{ + private const string RunId = "20260924T101530Z-a1b2"; + + [Fact] + public void ACompleteSpikeTranscriptPassesEveryRequiredCheck() + { + Transcript transcript = TranscriptParser.Parse(Encoding.UTF8.GetBytes( + "R\tmain-form\tok\t\"ready\"\n" + + "R\tload-plugin\tok\t\"0\"\n" + + "R\tharness-ready\tok\t\"ready\"\n" + + "R\tstatus\tok\t\"{\\\"ok\\\":true}\"\n" + + "R\truntime\tok\t\"{\\\"ok\\\":true}\"\n" + + "R\tcapabilities\tok\t\"{\\\"ok\\\":true}\"\n" + + "R\tsettings-probe\terror\t\"attempt to index a nil value\"\n" + + "R\ttoggle-disable\tnotexecuted\t\"Operator: untick it\"\n" + + "R\ttoggle-enable\tnotexecuted\t\"Operator: tick it\"\n" + + "DONE\n")); + + QualificationReceipt[] receipts = [.. LiveSandboxSession.SpikeReceipts(RunId, "S0", transcript, SessionOutcome.Completed, true, [], [], [])]; + + Assert.All(LiveSandboxSession.SpikeRequiredChecks, check => Assert.Equal(ReceiptStatus.Passed, + Assert.Single(receipts, receipt => receipt.Check == check).Status)); + Assert.Equal(ReceiptStatus.NotExecuted, Assert.Single(receipts, static receipt => receipt.Check == "settings-probe").Status); + QualificationReceipt[] toggles = [.. receipts.Where(static receipt => receipt.Check.StartsWith("toggle-", StringComparison.Ordinal))]; + Assert.All(toggles, static receipt => Assert.Equal(ReceiptStatus.NotExecuted, receipt.Status)); + Assert.Equal(["Operator: untick it", "Operator: tick it"], toggles.Select(static receipt => receipt.Observation)); + Assert.All(receipts, static receipt => Assert.Equal(("S0", "S0", "C3"), (receipt.Session, receipt.Scenario, receipt.Level))); + } + + [Fact] + public void AToggleTheDriverSawDonePasses() + { + Transcript transcript = TranscriptParser.Parse(Encoding.UTF8.GetBytes( + "R\ttoggle-disable\tok\t\"disabled\"\n" + + "R\ttoggle-enable\tnotexecuted\t\"skipped by the operator: Operator: tick it\"\n" + + "DONE\n")); + + QualificationReceipt[] receipts = [.. LiveSandboxSession.SpikeReceipts(RunId, "S0", transcript, SessionOutcome.Completed, true, [], [], [])]; + + Assert.Equal(ReceiptStatus.Passed, Assert.Single(receipts, static receipt => receipt.Check == "toggle-disable").Status); + Assert.Equal(ReceiptStatus.NotExecuted, Assert.Single(receipts, static receipt => receipt.Check == "toggle-enable").Status); + } + + [Fact] + public void MissingStepsAndADirtyWorkstationFail() + { + Transcript transcript = TranscriptParser.Parse(Encoding.UTF8.GetBytes("R\tstatus\tok\t\"not json\"\n")); + + QualificationReceipt[] receipts = + [ + .. LiveSandboxSession.SpikeReceipts(RunId, "S0", transcript, SessionOutcome.TimedOut, false, + ["host executable SHA-256 A became B"], ["process 'gtutorial-x86_64' (id 7) is running"], ["speedhack-x86_64.dll"]) + ]; + + Assert.All(LiveSandboxSession.SpikeRequiredChecks, check => Assert.Equal(ReceiptStatus.Failed, + Assert.Single(receipts, receipt => receipt.Check == check).Status)); + Assert.Equal("not reached", Assert.Single(receipts, static receipt => receipt.Check == "load-plugin").Observation); + Assert.Equal("Ok: not json", Assert.Single(receipts, static receipt => receipt.Check == "status").Observation); + Assert.Equal("speedhack-x86_64.dll", Assert.Single(receipts, static receipt => receipt.Check == "no-injected-module").Observation); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSpikeTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSpikeTests.cs new file mode 100644 index 0000000..6323a46 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSandboxSpikeTests.cs @@ -0,0 +1,43 @@ +using System.Runtime.Versioning; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// S0, the spike of the live qualification (runner specification): build the qualification harness from the packed +/// packages, load it into a sandboxed Cheat Engine 7.7 on gtutorial-x86_64, call status, runtime and capabilities(1), +/// inspect the settings form read-only, close Cheat Engine, and prove that the user state is restored, the source +/// installation untouched and no process left. Its receipts are never committed; its facts go to the README. +/// +/// +/// A live fact: never run in CI (both legs exclude Category=LiveQualification), never skipped. Without the +/// opt-in of it fails at once with the instructions. +/// +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S0")] +[SupportedOSPlatform("windows")] +public sealed class LiveSandboxSpikeTests(LiveQualificationFixture fixture) +{ + [Fact] + public async Task SandboxedHarnessAnswersAndTheWorkstationIsLeftAsItWasAsync() + { + LiveQualificationInputs inputs = fixture.RequireAuthorization(); + + LiveSessionResult result = await LiveSandboxSession.RunSpikeAsync(inputs, fixture.Feed, CheatEngineRegistryGuard.ForWorkstation(), + TestContext.Current.CancellationToken); + + foreach (QualificationReceipt receipt in result.Receipts) + { + TestContext.Current.TestOutputHelper?.WriteLine($"receipt[{receipt.Check}] {receipt.Status}: {receipt.Observation}"); + } + + TestContext.Current.TestOutputHelper?.WriteLine($"run directory: {result.Layout.RunDirectory}"); + Assert.True(result.UserStateRestored, "The Cheat Engine user state was not restored and verified."); + Assert.Empty(result.InstallationChanges); + Assert.Empty(result.LeftoverProcesses); + Assert.Equal(SessionOutcome.Completed, result.Outcome); + Assert.All(result.Receipts, static receipt => Assert.True(receipt.Status != ReceiptStatus.Failed, $"{receipt.Check}: {receipt.Observation}")); + Assert.All(LiveSandboxSession.SpikeRequiredChecks, check => Assert.Equal(ReceiptStatus.Passed, + Assert.Single(result.Receipts, receipt => receipt.Check == check).Status)); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LiveSessionTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSessionTests.cs new file mode 100644 index 0000000..90af711 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LiveSessionTests.cs @@ -0,0 +1,163 @@ +using System.Runtime.Versioning; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// Runs one session of and asserts what a session itself must guarantee. +[SupportedOSPlatform("windows")] +internal static class LiveSessions +{ + /// + /// Runs in the collection's run: the workstation is left as it was, the driver ran to its + /// end, and no check failed. A NotExecuted check is recorded as such and does not fail the fact: whether the + /// release can go ahead is decided on the recorded summary, never by skipping or inventing a pass. + /// + internal static async Task RunAsync(LiveQualificationFixture fixture, QualificationSessionPlan plan) + { + LiveQualificationInputs inputs = fixture.RequireAuthorization(); + + LiveSessionResult result = await LiveSandboxSession.RunSessionAsync(plan, inputs, fixture.Feed, + CheatEngineRegistryGuard.ForWorkstation(), fixture.Run(inputs), TestContext.Current.CancellationToken); + + foreach (QualificationReceipt receipt in result.Receipts) + { + TestContext.Current.TestOutputHelper?.WriteLine( + $"receipt[{receipt.Scenario}/{receipt.Check}] {receipt.Status}: {receipt.Observation}"); + } + + TestContext.Current.TestOutputHelper?.WriteLine($"run directory: {result.Layout.RunDirectory}"); + Assert.True(result.UserStateRestored, "The Cheat Engine user state was not restored and verified."); + Assert.Empty(result.InstallationChanges); + Assert.Empty(result.LeftoverProcesses); + Assert.Equal(SessionOutcome.Completed, result.Outcome); + Assert.All(result.Receipts, static receipt => Assert.True(receipt.Status != ReceiptStatus.Failed, + $"{receipt.Scenario}/{receipt.Check}: {receipt.Observation}")); + } +} + +/// S1: the x64 core on gtutorial-x86_64. +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S1")] +[Trait("Qualification", "Q05")] +[Trait("Qualification", "Q16.b")] +[Trait("Qualification", "Q19")] +[Trait("Qualification", "Q20")] +[Trait("Qualification", "Q21")] +[Trait("Qualification", "Q25")] +[Trait("Qualification", "Q26")] +[Trait("Qualification", "Q27")] +[Trait("Qualification", "Q28")] +[Trait("Qualification", "Q29")] +[Trait("Qualification", "Q30.a")] +[Trait("Qualification", "Q31")] +[Trait("Qualification", "Q32")] +[Trait("Qualification", "Q33")] +[Trait("Qualification", "Q34")] +[Trait("Qualification", "Q35")] +[Trait("Qualification", "Q40")] +[Trait("Qualification", "Q45")] +[Trait("Qualification", "Q46")] +[SupportedOSPlatform("windows")] +public sealed class LiveSessionS1Tests(LiveQualificationFixture fixture) +{ + [Fact] + public Task X64CoreSessionRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S1); + } +} + +/// S2: lifecycle and faults, without the Auto Assembler opt-in. +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S2")] +[Trait("Qualification", "Q05")] +[Trait("Qualification", "Q06")] +[Trait("Qualification", "Q16")] +[Trait("Qualification", "Q43")] +[Trait("Qualification", "Q44")] +[Trait("Qualification", "Q46")] +[SupportedOSPlatform("windows")] +public sealed class LiveSessionS2Tests(LiveQualificationFixture fixture) +{ + [Fact] + public Task LifecycleSessionRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S2); + } +} + +/// S3: target identity on two gtutorial-x86_64 instances and a file opened as a process. +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S3")] +[Trait("Qualification", "Q26")] +[Trait("Qualification", "Q28")] +[Trait("Qualification", "Q30.a")] +[Trait("Qualification", "Q30.b")] +[Trait("Qualification", "Q32")] +[Trait("Qualification", "Q35")] +[SupportedOSPlatform("windows")] +public sealed class LiveSessionS3Tests(LiveQualificationFixture fixture) +{ + [Fact] + public Task TargetIdentitySessionRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S3); + } +} + +/// S4: the x86 target gtutorial-i386. +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S4")] +[Trait("Qualification", "Q21")] +[Trait("Qualification", "Q28")] +[Trait("Qualification", "Q32")] +[SupportedOSPlatform("windows")] +public sealed class LiveSessionS4Tests(LiveQualificationFixture fixture) +{ + [Fact] + public Task X86SessionRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S4); + } +} + +/// S5: coexistence, in both load orders. +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S5")] +[Trait("Qualification", "Q09")] +[Trait("Qualification", "Q10")] +[Trait("Qualification", "Q16")] +[SupportedOSPlatform("windows")] +public sealed class LiveSessionS5Tests(LiveQualificationFixture fixture) +{ + [Fact] + public Task PluginANeighbourPluginBOrderRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S5a); + } + + [Fact] + public Task NeighbourPluginAPluginBOrderRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S5b); + } +} + +/// S6: the template, instantiated from the packed Templates package. +[Collection(LiveQualificationSerialGroup.Name)] +[Trait("Category", "LiveQualification")] +[Trait("Session", "S6")] +[Trait("Qualification", "Q40")] +[SupportedOSPlatform("windows")] +public sealed class LiveSessionS6Tests(LiveQualificationFixture fixture) +{ + [Fact] + public Task TemplateSessionRecordsItsReceiptsAsync() + { + return LiveSessions.RunAsync(fixture, SessionPlans.S6); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LuaDriverScript.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LuaDriverScript.cs new file mode 100644 index 0000000..903f9e3 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LuaDriverScript.cs @@ -0,0 +1,559 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using System.Text.RegularExpressions; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// Renders C# values as Lua 5.3 source literals. +internal static class LuaLiteral +{ + /// A double-quoted Lua string: printable ASCII as is, everything else as \ddd UTF-8 byte escapes. + internal static string String(string value) + { + ArgumentNullException.ThrowIfNull(value); + StringBuilder literal = new("\""); + foreach (byte current in Encoding.UTF8.GetBytes(value)) + { + switch (current) + { + case (byte) '"': + literal.Append("\\\""); + break; + case (byte) '\\': + literal.Append("\\\\"); + break; + case >= 0x20 and < 0x7F: + literal.Append((char) current); + break; + default: + literal.Append(CultureInfo.InvariantCulture, $"\\{current:D3}"); + break; + } + } + + return literal.Append('"').ToString(); + } + + /// A Lua integer. + internal static string Integer(long value) + { + return value.ToString(CultureInfo.InvariantCulture); + } +} + +/// One call of a harness Lua function, recorded under . +/// The transcript step name. +/// The function name after cheatengine_client_qualification_. +/// Lua source expressions, built with . +internal sealed record LuaHarnessCall(string Step, string Function, IReadOnlyList Arguments); + +/// What the S0 spike's driver does. +/// The session id, for the header comment. +/// Where the driver writes its transcript. +/// The disposable target to open. +/// The plugin assembly to loadPlugin. +/// The name Cheat Engine shows in Settings > Plugins. +/// The harness calls, in order. +/// +/// Whether the S0 spike proved that the plugin can be disabled and enabled through getSettingsForm(). Until +/// it did, the toggle steps are operator steps (): the driver prompts the +/// operator and waits for the toggle's effect, and never toggles a plugin itself. +/// +internal sealed record LuaDriverPlan( + string Session, + string TranscriptPath, + int TargetProcessId, + string PluginPath, + string PluginDisplayName, + IReadOnlyList Calls, + bool SettingsToggleProven); + +/// How the driver runs one step. +internal enum LuaDriverStepKind +{ + /// The step runs once; whatever it returns (even nil) is recorded ok. + Once, + + /// The step runs once per tick until it returns a value, at most times. + Poll, + + /// + /// A step the operator performs (a plugin toggle in Settings > Plugins, plan A12). The driver shows the prompt + /// in a window that leaves Cheat Engine usable and, each tick, runs the body, which returns a value once the + /// step's effect is observed; a step without a body waits for the operator's Done instead. The value (or the + /// confirmation) is recorded ok; the operator's Skip, or no effect within + /// ticks, is recorded notexecuted with the prompt. + /// + Operator +} + +/// +/// One step of a driver: its transcript name, how it runs and its Lua body (the body of a function whose return +/// value the transcript records; for an operator step, the check of its effect), and an operator step's prompt. +/// +/// The transcript step name, unique in the driver. +/// How the driver runs it. +/// +/// The Lua function body, lines without indentation; empty for an operator step whose effect the driver cannot +/// observe, which then waits for the operator's confirmation. +/// +/// How many ticks a poll or operator step may take. +/// What the operator must do, for an operator step. +internal sealed record LuaDriverStep(string Name, LuaDriverStepKind Kind, string Body, int Attempts = 0, string? Prompt = null); + +/// The reviewed building blocks of every driver, so a session plan only composes them. +[SupportedOSPlatform("windows")] +internal static class LuaDriverSteps +{ + /// Waits for Cheat Engine's main form. + internal static LuaDriverStep MainForm() + { + return new LuaDriverStep("main-form", LuaDriverStepKind.Poll, """ + if getMainForm() == nil then return nil end + return "ready" + """, LuaDriverScript.MainFormAttempts); + } + + /// Selects a process through Cheat Engine itself (openProcess), then waits until it is opened. + internal static IEnumerable OpenProcess(string step, int processId) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(processId); + string pid = LuaLiteral.Integer(processId); + yield return new LuaDriverStep(step, LuaDriverStepKind.Once, $"return openProcess({pid})"); + yield return new LuaDriverStep("opened-" + TrimOpen(step), LuaDriverStepKind.Poll, $""" + if getOpenedProcessID() ~= {pid} then return nil end + return getOpenedProcessID() + """, LuaDriverScript.ShortAttempts); + } + + /// Loads a plugin assembly with loadPlugin. + internal static LuaDriverStep LoadPlugin(string step, string pluginPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath); + return new LuaDriverStep(step, LuaDriverStepKind.Once, $"return loadPlugin({LuaLiteral.String(pluginPath)})"); + } + + /// Waits until a Lua global is a function: the plugin that exports it is enabled. + internal static LuaDriverStep GlobalReady(string step, string global) + { + return new LuaDriverStep(step, LuaDriverStepKind.Poll, $""" + if type(_G[{LuaLiteral.String(global)}]) ~= "function" then return nil end + return "ready" + """, LuaDriverScript.ShortAttempts); + } + + /// Calls one harness function once. + internal static LuaDriverStep Call(string step, string function, params string[] arguments) + { + return new LuaDriverStep(step, LuaDriverStepKind.Once, CallBody(function, arguments)); + } + + /// Calls one harness function each tick until its observation is no longer "pending":true. + internal static LuaDriverStep Poll(string step, string function, int attempts, params string[] arguments) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(attempts); + string pending = LuaLiteral.String("\"pending\":true"); + return new LuaDriverStep(step, LuaDriverStepKind.Poll, CallBody(function, arguments, "local value = ") + + "\n" + $"if string.find(value, {pending}, 1, true) ~= nil then return nil end" + "\nreturn value", + attempts); + } + + /// Runs a reviewed Lua body once (driver setup, a check of Lua state, a call of another plugin's global). + internal static LuaDriverStep Lua(string step, string body) + { + ArgumentException.ThrowIfNullOrWhiteSpace(body); + return new LuaDriverStep(step, LuaDriverStepKind.Once, body); + } + + /// Calls a global of another plugin once and returns its value, or raises when it is not a function. + internal static LuaDriverStep CallGlobal(string step, string global) + { + string name = LuaLiteral.String(global); + return new LuaDriverStep(step, LuaDriverStepKind.Once, $""" + local callee = _G[{name}] + if type(callee) ~= "function" then error("no function " .. {name}) end + return callee() + """); + } + + /// + /// A step the operator performs (): returns a + /// value once the driver observes it done, or is empty when only the operator's confirmation can end the step. + /// + internal static LuaDriverStep Operator(string step, string prompt, string effect) + { + ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + ArgumentNullException.ThrowIfNull(effect); + return new LuaDriverStep(step, LuaDriverStepKind.Operator, effect, LuaDriverScript.OperatorAttempts, prompt); + } + + /// + /// The operator toggle of a plugin through Settings > Plugins, until the S0 spike proves that the driver can + /// perform it: the step ends when , a function the plugin exports, is gone after a + /// disable or back after an enable. + /// + internal static LuaDriverStep Toggle(string step, bool enable, string pluginDisplayName, string global, string? note = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(global); + string name = LuaLiteral.String(global); + string effect = enable + ? $""" + if type(_G[{name}]) ~= "function" then return nil end + return "enabled" + """ + : $""" + if type(_G[{name}]) == "function" then return nil end + return "disabled" + """; + return Operator(step, TogglePrompt(enable, pluginDisplayName, note) + + " The driver continues once the plugin's functions are " + (enable ? "back." : "gone."), effect); + } + + /// + /// An operator toggle whose effect the driver cannot observe (an enable that must fail): the step ends with the + /// operator's Done. + /// + internal static LuaDriverStep ConfirmedToggle(string step, bool enable, string pluginDisplayName, string note) + { + ArgumentException.ThrowIfNullOrWhiteSpace(note); + return Operator(step, TogglePrompt(enable, pluginDisplayName, note) + " Then press Done.", string.Empty); + } + + /// Inspects the settings form read-only: the check list boxes it holds. + internal static LuaDriverStep SettingsProbe() + { + return new LuaDriverStep("settings-probe", LuaDriverStepKind.Once, """ + local form = getSettingsForm() + if form == nil then return "no settings form" end + local found = {} + for index = 0, form.ComponentCount - 1 do + local component = form.Component[index] + if string.find(component.ClassName, "CheckListBox", 1, true) ~= nil then + found[#found + 1] = component.Name .. ":" .. component.ClassName .. ":" .. tostring(component.Items.Count) + end + end + return table.concat(found, ";") + """); + } + + /// Clears the address list, so no save prompt can block closeCE(). + internal static LuaDriverStep ClearAddressList() + { + return new LuaDriverStep("clear-address-list", LuaDriverStepKind.Once, """ + getAddressList().clear() + return getAddressList().Count + """); + } + + private static string CallBody(string function, string[] arguments, string resultPrefix = "return ") + { + ArgumentNullException.ThrowIfNull(arguments); + string global = LuaLiteral.String(LuaDriverScript.HarnessFunctionPrefix + function); + return $""" + local harness = _G[{global}] + if type(harness) ~= "function" then error("the harness does not define " .. {global}) end + {resultPrefix}harness({string.Join(", ", arguments)}) + """; + } + + private static string TrimOpen(string step) + { + return step.StartsWith("open-", StringComparison.Ordinal) ? step["open-".Length..] : step; + } + + private static string TogglePrompt(bool enable, string pluginDisplayName, string? note) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName); + string prompt = $"Operator: in Edit > Settings > Plugins, {(enable ? "tick" : "untick")} '{pluginDisplayName}' and press OK."; + return note is null ? prompt : prompt + " " + note; + } +} + +/// +/// Generates the autorun driver of one session: a createTimer state machine that runs on Cheat Engine's main +/// thread, one step per tick, each step under pcall. A session composes its steps from +/// : it waits for the main form, opens the targets through Cheat Engine, loads the +/// plugins, calls them, clears the address list so no save prompt can block, writes DONE and calls +/// closeCE(). Until the spike proves that the driver can toggle a plugin, each toggle is an operator step +/// (plan A12): a window that leaves Cheat Engine usable shows the prompt with a Skip button (and Done when the effect +/// cannot be observed), and the driver waits up to 90 seconds for the plugin's functions to disappear or come back. +/// Every step appends one transcript line R<TAB>step<TAB>ok|error|notexecuted<TAB>%q and +/// flushes it, so a crash keeps what ran. +/// +[SupportedOSPlatform("windows")] +internal static partial class LuaDriverScript +{ + /// The prefix of every harness Lua function. + internal const string HarnessFunctionPrefix = "cheatengine_client_qualification_"; + + /// The timer interval, in milliseconds. + internal const int IntervalMilliseconds = 250; + + /// Ticks to wait for the main form (60 seconds). + internal const int MainFormAttempts = 240; + + /// Ticks to wait for the target to be opened, or for the harness functions to appear (10 seconds). + internal const int ShortAttempts = 40; + + /// Ticks to wait for an operator step (90 seconds); five of them still fit a session's 10 minutes. + internal const int OperatorAttempts = 360; + + /// Renders the S0 driver of . + internal static string Render(LuaDriverPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentException.ThrowIfNullOrWhiteSpace(plan.PluginPath); + ArgumentException.ThrowIfNullOrWhiteSpace(plan.PluginDisplayName); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(plan.TargetProcessId); + if (plan.SettingsToggleProven) + { + throw new NotSupportedException("No settings toggle is proven yet: the S0 spike records whether getSettingsForm() " + + "can toggle a plugin, and the change that records it adds the toggle steps."); + } + + List steps = [LuaDriverSteps.MainForm(), .. LuaDriverSteps.OpenProcess("open-process", plan.TargetProcessId)]; + steps.Add(LuaDriverSteps.LoadPlugin("load-plugin", plan.PluginPath)); + steps.Add(LuaDriverSteps.GlobalReady("harness-ready", HarnessFunctionPrefix + "status")); + foreach (LuaHarnessCall call in plan.Calls) + { + RequireName(call.Function, FunctionName(), nameof(plan.Calls)); + steps.Add(LuaDriverSteps.Call(call.Step, call.Function, [.. call.Arguments])); + } + + steps.Add(LuaDriverSteps.SettingsProbe()); + steps.Add(LuaDriverSteps.Toggle("toggle-disable", false, plan.PluginDisplayName, HarnessFunctionPrefix + "status")); + steps.Add(LuaDriverSteps.Toggle("toggle-enable", true, plan.PluginDisplayName, HarnessFunctionPrefix + "status")); + steps.Add(LuaDriverSteps.ClearAddressList()); + return RenderSteps(plan.Session, plan.TranscriptPath, steps); + } + + /// Renders a driver that runs in order, then writes DONE and closes. + internal static string RenderSteps(string session, string transcriptPath, IReadOnlyList steps) + { + ArgumentException.ThrowIfNullOrWhiteSpace(transcriptPath); + ArgumentNullException.ThrowIfNull(steps); + RequireName(session, StepName(), nameof(session)); + HashSet names = new(StringComparer.Ordinal); + foreach (LuaDriverStep step in steps) + { + RequireName(step.Name, StepName(), nameof(steps)); + if (!names.Add(step.Name)) + { + throw new ArgumentException($"The step '{step.Name}' appears twice; transcript steps are unique.", nameof(steps)); + } + } + + StringBuilder script = new(); + script.Append(CultureInfo.InvariantCulture, $$""" + -- Generated by CheatEngine.Client.Tests (LiveQualification) for session {{session}}. Never committed: it + -- lives in the run's sandboxed Cheat Engine only. One step per timer tick on the main thread, each under pcall; + -- every step appends "Rstepok|error|notexecuted%q" to the transcript and flushes it. + local transcript = assert(io.open({{LuaLiteral.String(transcriptPath)}}, "wb")) + + local function record(step, status, value) + transcript:write(string.format("R\t%s\t%s\t%q\n", step, status, tostring(value))) + transcript:flush() + end + + local steps = { + + """); + foreach (LuaDriverStep step in steps) + { + script.Append(RenderStep(step)); + } + + script.Append(CultureInfo.InvariantCulture, $$""" + } + + local index = 1 + local attempts = 0 + local finished = false + + -- The operator window: it does not block Cheat Engine, so the operator can open Settings > Plugins. + local prompt = nil + local answer = nil + + local function closePrompt() + local form = prompt + prompt = nil + if form ~= nil then + pcall(function() form.destroy() end) + end + answer = nil + end + + local function showPrompt(step) + local form = createForm(false) + prompt = form + form.Caption = "CheatEngine.Client qualification: operator step " .. step.name + form.setSize(560, 180) + pcall(function() form.FormStyle = "fsStayOnTop" end) + local text = createMemo(form) + text.setPosition(12, 12) + text.setSize(536, 96) + text.WordWrap = true + pcall(function() text.ReadOnly = true end) + text.append(step.prompt) + local skip = createButton(form) + skip.Caption = "Skip" + skip.setPosition(460, 124) + skip.OnClick = function() answer = "skip" end + if step.run == nil then + local done = createButton(form) + done.Caption = "Done" + done.setPosition(372, 124) + done.OnClick = function() answer = "done" end + end + form.OnClose = function() + -- Closing the window skips the step; a window the driver destroys is no longer the prompt. + if prompt == form and answer == nil then answer = "skip" end + return 1 -- caHide + end + form.centerScreen() + form.show() + end + + local function operatorStep(step) + local ok, value = true, nil + if step.run ~= nil then + ok, value = pcall(step.run) + elseif answer == "done" then + value = "confirmed by the operator" + end + if not ok then + record(step.name, "error", value) + elseif value ~= nil then + record(step.name, "ok", value) + elseif answer == "skip" then + record(step.name, "notexecuted", "skipped by the operator: " .. step.prompt) + elseif attempts + 1 >= step.attempts then + record(step.name, "notexecuted", "no operator action within {{OperatorAttempts * IntervalMilliseconds / 1000}} seconds: " .. step.prompt) + else + if prompt == nil then + local shown, failure = pcall(showPrompt, step) + if not shown then + closePrompt() + record(step.name, "error", failure) + return true + end + end + attempts = attempts + 1 + return false + end + closePrompt() + return true + end + + local function advance() + local step = steps[index] + if step == nil then + finished = true + pcall(function() + transcript:write("DONE\n") + transcript:flush() + transcript:close() + end) + closeCE() + return + end + if step.kind == "operator" then + if not operatorStep(step) then return end + index = index + 1 + attempts = 0 + return + end + local ok, value = pcall(step.run) + if not ok then + record(step.name, "error", value) + elseif value ~= nil or step.kind == "once" then + record(step.name, "ok", value) + else + attempts = attempts + 1 + if attempts < step.attempts then return end + record(step.name, "error", "no result after " .. attempts .. " attempts") + end + index = index + 1 + attempts = 0 + end + + local driver = createTimer(nil, false) + driver.Interval = {{IntervalMilliseconds}} + driver.OnTimer = function(sender) + sender.Enabled = false + local ok, failure = pcall(advance) + if not ok then + -- The driver itself failed: still clear the address list, then write DONE and close. + pcall(closePrompt) + pcall(record, "driver", "error", failure) + index = index < #steps and #steps or #steps + 1 + end + if not finished then sender.Enabled = true end + end + driver.Enabled = true + + """); + return script.ToString().ReplaceLineEndings("\n"); + } + + /// The S0 plan: load the harness, call status, runtime and capabilities(1). + internal static LuaDriverPlan SpikePlan(string transcriptPath, int targetProcessId, string pluginPath, string pluginDisplayName) + { + return new LuaDriverPlan("S0", transcriptPath, targetProcessId, pluginPath, pluginDisplayName, + [ + new LuaHarnessCall("status", "status", []), + new LuaHarnessCall("runtime", "runtime", []), + new LuaHarnessCall("capabilities", "capabilities", [LuaLiteral.Integer(1)]) + ], false); + } + + private static string RenderStep(LuaDriverStep step) + { + string name = LuaLiteral.String(step.Name); + StringBuilder text = new(); + if (step.Kind == LuaDriverStepKind.Operator) + { + ArgumentException.ThrowIfNullOrWhiteSpace(step.Prompt); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(step.Attempts); + text.Append(CultureInfo.InvariantCulture, + $" {{ name = {name}, kind = \"operator\", attempts = {step.Attempts}, prompt = {LuaLiteral.String(step.Prompt)}"); + if (step.Body.Length == 0) + { + return text.Append(" },\n").ToString(); + } + + text.Append(", run = function()\n"); + } + else if (step.Kind == LuaDriverStepKind.Poll) + { + text.Append(CultureInfo.InvariantCulture, + $" {{ name = {name}, kind = \"poll\", attempts = {step.Attempts}, run = function()\n"); + } + else + { + text.Append(CultureInfo.InvariantCulture, $" {{ name = {name}, kind = \"once\", run = function()\n"); + } + + foreach (string line in step.Body.ReplaceLineEndings("\n").TrimEnd('\n').Split('\n')) + { + text.Append(" ").Append(line).Append('\n'); + } + + return text.Append(" end },\n").ToString(); + } + + private static void RequireName(string value, Regex pattern, string parameter) + { + if (string.IsNullOrEmpty(value) || !pattern.IsMatch(value)) + { + throw new ArgumentException($"'{value}' is not a valid name ({pattern}).", parameter); + } + } + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9.-]*\z", RegexOptions.CultureInvariant, 1000)] + private static partial Regex StepName(); + + [GeneratedRegex(@"^[a-z][a-z0-9_]*\z", RegexOptions.CultureInvariant, 1000)] + private static partial Regex FunctionName(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/LuaDriverScriptTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/LuaDriverScriptTests.cs new file mode 100644 index 0000000..a2c8a17 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/LuaDriverScriptTests.cs @@ -0,0 +1,279 @@ +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The autorun driver, as reviewed text: every change to the Lua that runs inside Cheat Engine shows up here first. It +/// only calls functions the harness declares, never attempts an unproven settings toggle (it prompts the operator and +/// waits for the toggle's effect), and quotes every value. +/// +[SupportedOSPlatform("windows")] +public sealed class LuaDriverScriptTests +{ + private const string TranscriptPath = @"C:\runs\20260924T101530Z-a1b2\sessions\S0\transcript.txt"; + private const string PluginPath = @"C:\runs\20260924T101530Z-a1b2\plugins\harness\CheatEngine.Client.LivePlugin.Qualification.dll"; + + /// The reviewed S0 driver. + private const string SpikeDriver = """ + -- Generated by CheatEngine.Client.Tests (LiveQualification) for session S0. Never committed: it + -- lives in the run's sandboxed Cheat Engine only. One step per timer tick on the main thread, each under pcall; + -- every step appends "Rstepok|error|notexecuted%q" to the transcript and flushes it. + local transcript = assert(io.open("C:\\runs\\20260924T101530Z-a1b2\\sessions\\S0\\transcript.txt", "wb")) + + local function record(step, status, value) + transcript:write(string.format("R\t%s\t%s\t%q\n", step, status, tostring(value))) + transcript:flush() + end + + local steps = { + { name = "main-form", kind = "poll", attempts = 240, run = function() + if getMainForm() == nil then return nil end + return "ready" + end }, + { name = "open-process", kind = "once", run = function() + return openProcess(4242) + end }, + { name = "opened-process", kind = "poll", attempts = 40, run = function() + if getOpenedProcessID() ~= 4242 then return nil end + return getOpenedProcessID() + end }, + { name = "load-plugin", kind = "once", run = function() + return loadPlugin("C:\\runs\\20260924T101530Z-a1b2\\plugins\\harness\\CheatEngine.Client.LivePlugin.Qualification.dll") + end }, + { name = "harness-ready", kind = "poll", attempts = 40, run = function() + if type(_G["cheatengine_client_qualification_status"]) ~= "function" then return nil end + return "ready" + end }, + { name = "status", kind = "once", run = function() + local harness = _G["cheatengine_client_qualification_status"] + if type(harness) ~= "function" then error("the harness does not define " .. "cheatengine_client_qualification_status") end + return harness() + end }, + { name = "runtime", kind = "once", run = function() + local harness = _G["cheatengine_client_qualification_runtime"] + if type(harness) ~= "function" then error("the harness does not define " .. "cheatengine_client_qualification_runtime") end + return harness() + end }, + { name = "capabilities", kind = "once", run = function() + local harness = _G["cheatengine_client_qualification_capabilities"] + if type(harness) ~= "function" then error("the harness does not define " .. "cheatengine_client_qualification_capabilities") end + return harness(1) + end }, + { name = "settings-probe", kind = "once", run = function() + local form = getSettingsForm() + if form == nil then return "no settings form" end + local found = {} + for index = 0, form.ComponentCount - 1 do + local component = form.Component[index] + if string.find(component.ClassName, "CheckListBox", 1, true) ~= nil then + found[#found + 1] = component.Name .. ":" .. component.ClassName .. ":" .. tostring(component.Items.Count) + end + end + return table.concat(found, ";") + end }, + { name = "toggle-disable", kind = "operator", attempts = 360, prompt = "Operator: in Edit > Settings > Plugins, untick 'CheatEngine.Client Qualification Plugin' and press OK. The driver continues once the plugin's functions are gone.", run = function() + if type(_G["cheatengine_client_qualification_status"]) == "function" then return nil end + return "disabled" + end }, + { name = "toggle-enable", kind = "operator", attempts = 360, prompt = "Operator: in Edit > Settings > Plugins, tick 'CheatEngine.Client Qualification Plugin' and press OK. The driver continues once the plugin's functions are back.", run = function() + if type(_G["cheatengine_client_qualification_status"]) ~= "function" then return nil end + return "enabled" + end }, + { name = "clear-address-list", kind = "once", run = function() + getAddressList().clear() + return getAddressList().Count + end }, + } + + local index = 1 + local attempts = 0 + local finished = false + + -- The operator window: it does not block Cheat Engine, so the operator can open Settings > Plugins. + local prompt = nil + local answer = nil + + local function closePrompt() + local form = prompt + prompt = nil + if form ~= nil then + pcall(function() form.destroy() end) + end + answer = nil + end + + local function showPrompt(step) + local form = createForm(false) + prompt = form + form.Caption = "CheatEngine.Client qualification: operator step " .. step.name + form.setSize(560, 180) + pcall(function() form.FormStyle = "fsStayOnTop" end) + local text = createMemo(form) + text.setPosition(12, 12) + text.setSize(536, 96) + text.WordWrap = true + pcall(function() text.ReadOnly = true end) + text.append(step.prompt) + local skip = createButton(form) + skip.Caption = "Skip" + skip.setPosition(460, 124) + skip.OnClick = function() answer = "skip" end + if step.run == nil then + local done = createButton(form) + done.Caption = "Done" + done.setPosition(372, 124) + done.OnClick = function() answer = "done" end + end + form.OnClose = function() + -- Closing the window skips the step; a window the driver destroys is no longer the prompt. + if prompt == form and answer == nil then answer = "skip" end + return 1 -- caHide + end + form.centerScreen() + form.show() + end + + local function operatorStep(step) + local ok, value = true, nil + if step.run ~= nil then + ok, value = pcall(step.run) + elseif answer == "done" then + value = "confirmed by the operator" + end + if not ok then + record(step.name, "error", value) + elseif value ~= nil then + record(step.name, "ok", value) + elseif answer == "skip" then + record(step.name, "notexecuted", "skipped by the operator: " .. step.prompt) + elseif attempts + 1 >= step.attempts then + record(step.name, "notexecuted", "no operator action within 90 seconds: " .. step.prompt) + else + if prompt == nil then + local shown, failure = pcall(showPrompt, step) + if not shown then + closePrompt() + record(step.name, "error", failure) + return true + end + end + attempts = attempts + 1 + return false + end + closePrompt() + return true + end + + local function advance() + local step = steps[index] + if step == nil then + finished = true + pcall(function() + transcript:write("DONE\n") + transcript:flush() + transcript:close() + end) + closeCE() + return + end + if step.kind == "operator" then + if not operatorStep(step) then return end + index = index + 1 + attempts = 0 + return + end + local ok, value = pcall(step.run) + if not ok then + record(step.name, "error", value) + elseif value ~= nil or step.kind == "once" then + record(step.name, "ok", value) + else + attempts = attempts + 1 + if attempts < step.attempts then return end + record(step.name, "error", "no result after " .. attempts .. " attempts") + end + index = index + 1 + attempts = 0 + end + + local driver = createTimer(nil, false) + driver.Interval = 250 + driver.OnTimer = function(sender) + sender.Enabled = false + local ok, failure = pcall(advance) + if not ok then + -- The driver itself failed: still clear the address list, then write DONE and close. + pcall(closePrompt) + pcall(record, "driver", "error", failure) + index = index < #steps and #steps or #steps + 1 + end + if not finished then sender.Enabled = true end + end + driver.Enabled = true + """; + + [Fact] + public void TheSpikeDriverIsTheReviewedText() + { + string rendered = LuaDriverScript.Render(LuaDriverScript.SpikePlan(TranscriptPath, 4242, PluginPath, LiveSandboxSession.HarnessDisplayName)); + + // The driver is written with LF line endings and ends with one. + Assert.Equal(SpikeDriver.ReplaceLineEndings("\n") + "\n", rendered); + } + + [Fact] + public void LiteralsEscapeQuotesBackslashesControlAndNonAsciiBytes() + { + Assert.Equal("\"a\\\"b\\\\c\\010d\\195\\169\"", LuaLiteral.String("a\"b\\c\nd\u00e9")); + Assert.Equal("\"\"", LuaLiteral.String(string.Empty)); + Assert.Equal("-12", LuaLiteral.Integer(-12)); + } + + [Fact] + public void AnUnprovenSettingsToggleIsNeverAttempted() + { + LuaDriverPlan plan = LuaDriverScript.SpikePlan(TranscriptPath, 4242, PluginPath, LiveSandboxSession.HarnessDisplayName); + + string rendered = LuaDriverScript.Render(plan); + + Assert.Throws(() => LuaDriverScript.Render(plan with { SettingsToggleProven = true })); + Assert.Contains("name = \"toggle-disable\", kind = \"operator\"", rendered, StringComparison.Ordinal); + Assert.Contains("name = \"toggle-enable\", kind = \"operator\"", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("getSettingsForm", rendered[rendered.IndexOf("toggle-disable", StringComparison.Ordinal)..], + StringComparison.Ordinal); + Assert.DoesNotContain("Checked", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("ModalResult", rendered, StringComparison.Ordinal); + } + + [Theory] + [InlineData("bad step", "status")] + [InlineData("status", "Bad-Name")] + [InlineData("", "status")] + [InlineData("status\t", "status")] + public void InvalidStepOrFunctionNamesAreRefused(string step, string function) + { + LuaDriverPlan plan = LuaDriverScript.SpikePlan(TranscriptPath, 4242, PluginPath, LiveSandboxSession.HarnessDisplayName) with + { + Calls = [new LuaHarnessCall(step, function, [])] + }; + + Assert.Throws(() => LuaDriverScript.Render(plan)); + } + + [Fact] + public void TheDriverCallsOnlyFunctionsTheHarnessDeclares() + { + string functions = File.ReadAllText(RepositoryLayout.Combine( + "tests/CheatEngine.Client.LivePlugin.Qualification/QualificationLuaFunctions.cs")); + string plugin = File.ReadAllText(RepositoryLayout.Combine("tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs")); + LuaDriverPlan plan = LuaDriverScript.SpikePlan(TranscriptPath, 4242, PluginPath, LiveSandboxSession.HarnessDisplayName); + + Assert.Contains($"DisplayName = \"{LiveSandboxSession.HarnessDisplayName}\";", plugin, StringComparison.Ordinal); + Assert.Contains($"[LuaFunction(\"{LuaDriverScript.HarnessFunctionPrefix}status\")]", functions, StringComparison.Ordinal); + Assert.All(plan.Calls, call => Assert.Contains($"[LuaFunction(\"{LuaDriverScript.HarnessFunctionPrefix}{call.Function}\")]", + functions, StringComparison.Ordinal)); + Assert.Contains("[CheatEnginePlugin(DisplayName)]", plugin, StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/NeighbourPluginSource.cs b/tests/CheatEngine.Client.Tests/LiveQualification/NeighbourPluginSource.cs new file mode 100644 index 0000000..d28fd33 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/NeighbourPluginSource.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The plain CheatEngine.SDK 1.x plugin that S5 loads next to the Client plugins (Q10). It is generated in a temporary +/// consumer at run time, never committed, and references CheatEngine.SDK 1.x from nuget.org and nothing of the Client. +/// Its only export, , reports its identity as booleans (plan A12): the SDK Hosting major it +/// loaded, whether its native bridge equals the one its package ships, whether it runs outside the default load +/// context, and whether a CheatEngine.SDK.Hosting of major 2 is loaded in another context. No version literal, hash or +/// path of the retired package appears in its output, so no receipt can carry one. +/// +/// +/// Its shape follows the quick start of the CheatEngine.SDK 1.x README: a CheatEnginePlugin with +/// [CheatEnginePlugin], whose OnEnable and OnDisable register and unregister the generated +/// [LuaFunction] set through LuaRuntime.AcquireState(). That shape was compared by hand, once, with the +/// SDK's published 1.x sample; NeighbourPluginSourceTests checks the generated text without reading the SDK +/// repository. +/// +internal static partial class NeighbourPluginSource +{ + /// The CheatEngine.SDK version the neighbour references: the previous major. + internal const string SdkVersion = "1.0.0"; + + /// The assembly name of the neighbour. + internal const string AssemblyName = "CheatEngine.Client.Qualification.SdkNeighbour"; + + /// The one Lua global the neighbour exports. + internal const string IdentityGlobal = "cheatengine_client_qualification_sdk1_identity"; + + /// The name Cheat Engine shows for the neighbour. + internal const string DisplayName = "CheatEngine.Client Qualification SDK 1.x Neighbour"; + + /// The project of the neighbour: the SDK 1.x package, x64, no lock file, no Client reference. + internal static string Project() + { + return $""" + + + net10.0 + 14.0 + enable + enable + {AssemblyName} + QualificationSdkNeighbour + x64 + true + true + false + + + + + + """; + } + + /// + /// The plugin source, with the SHA-256 of the bridge the neighbour's package ships, measured by the runner, as the + /// value its bridge is compared with. + /// + internal static string Source(string packagedBridgeSha256) + { + if (!Sha256().IsMatch(packagedBridgeSha256)) + { + throw new ArgumentException("The packaged bridge hash must be 64 lower-case hex digits.", nameof(packagedBridgeSha256)); + } + + return string.Create(CultureInfo.InvariantCulture, $$"""" + using System.Reflection; + using System.Runtime.Loader; + using System.Security.Cryptography; + + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Hosting.Plugin; + using CheatEngine.SDK.Lua.Runtime; + + namespace QualificationSdkNeighbour; + + [CheatEnginePlugin("{{DisplayName}}")] + public sealed class NeighbourPlugin : CheatEnginePlugin + { + protected override void OnEnable() => NeighbourCommands.RegisterLuaFunctions(LuaRuntime.AcquireState()); + + protected override void OnDisable() => NeighbourCommands.UnregisterLuaFunctions(LuaRuntime.AcquireState()); + } + + internal static partial class NeighbourCommands + { + private const string PackagedBridgeSha256 = "{{packagedBridgeSha256}}"; + + [LuaFunction("{{IdentityGlobal}}")] + public static string Identity() + { + Assembly hosting = typeof(CheatEnginePlugin).Assembly; + AssemblyLoadContext? own = AssemblyLoadContext.GetLoadContext(typeof(NeighbourPlugin).Assembly); + bool otherMajorLoaded = AssemblyLoadContext.All + .Where(context => !ReferenceEquals(context, own)) + .SelectMany(static context => context.Assemblies) + .Any(static assembly => assembly.GetName() is { Name: "CheatEngine.SDK.Hosting", Version.Major: 2 }); + return "Sdk1Neighbour=Answering" + + "; SdkHostingMajorIs1=" + (hosting.GetName().Version?.Major == 1) + + "; BridgeMatchesPackage=" + BridgeMatchesPackage() + + "; OwnLoadContextIsNotDefault=" + (own is not null && !ReferenceEquals(own, AssemblyLoadContext.Default)) + + "; SdkHostingMajor2LoadedElsewhere=" + otherMajorLoaded; + } + + private static bool BridgeMatchesPackage() + { + string? folder = Path.GetDirectoryName(typeof(NeighbourPlugin).Assembly.Location); + string bridge = folder is null ? string.Empty : Path.Combine(folder, "cheatengine-sdk-lua-bridge.dll"); + if (!File.Exists(bridge)) + { + return false; + } + + using FileStream stream = File.OpenRead(bridge); + return string.Equals(Convert.ToHexStringLower(SHA256.HashData(stream)), PackagedBridgeSha256, StringComparison.Ordinal); + } + } + + """"); + } + + /// The identity booleans the neighbour's answer carries, by name; empty when the answer has another shape. + internal static IReadOnlyDictionary ParseIdentity(string answer) + { + ArgumentNullException.ThrowIfNull(answer); + Dictionary facts = new(StringComparer.Ordinal); + if (!answer.StartsWith("Sdk1Neighbour=Answering", StringComparison.Ordinal)) + { + return facts; + } + + foreach (Match match in IdentityFact().Matches(answer)) + { + facts[match.Groups["name"].Value] = string.Equals(match.Groups["value"].Value, "True", StringComparison.Ordinal); + } + + return facts; + } + + [GeneratedRegex("^[0-9a-f]{64}$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex Sha256(); + + [GeneratedRegex(@"; (?[A-Za-z0-9]+)=(?True|False)\b", RegexOptions.CultureInvariant, 1000)] + private static partial Regex IdentityFact(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/NeighbourPluginSourceTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/NeighbourPluginSourceTests.cs new file mode 100644 index 0000000..8e78ff0 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/NeighbourPluginSourceTests.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The generated CheatEngine.SDK 1.x neighbour of S5, as text: a plain SDK plugin with one Lua export, no Client +/// reference, and an answer made of booleans only. The shape was compared once, by hand, with the SDK's published +/// 1.x quick start; this test never reads the SDK repository. +/// +public sealed partial class NeighbourPluginSourceTests +{ + private const string BridgeSha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + [Fact] + public void TheNeighbourIsAPlainSdkPluginWithOneExport() + { + string source = NeighbourPluginSource.Source(BridgeSha256); + + Assert.Contains($"[CheatEnginePlugin(\"{NeighbourPluginSource.DisplayName}\")]", source, StringComparison.Ordinal); + Assert.Contains("public sealed class NeighbourPlugin : CheatEnginePlugin", source, StringComparison.Ordinal); + Assert.Contains("NeighbourCommands.RegisterLuaFunctions(LuaRuntime.AcquireState())", source, StringComparison.Ordinal); + Assert.Contains("NeighbourCommands.UnregisterLuaFunctions(LuaRuntime.AcquireState())", source, StringComparison.Ordinal); + Assert.Equal([NeighbourPluginSource.IdentityGlobal], LuaFunction().Matches(source).Select(static match => match.Groups["name"].Value)); + Assert.Contains($"PackagedBridgeSha256 = \"{BridgeSha256}\"", source, StringComparison.Ordinal); + } + + [Fact] + public void TheNeighbourReferencesNothingOfTheClient() + { + string source = NeighbourPluginSource.Source(BridgeSha256); + XDocument project = XDocument.Parse(NeighbourPluginSource.Project()); + + Assert.DoesNotContain("CheatEngine.Client.", source, StringComparison.Ordinal); + Assert.All(UsingDirective().Matches(source), static match => + Assert.Matches(@"^(System(\.[A-Za-z]+)*|CheatEngine\.SDK(\.[A-Za-z]+)*)$", match.Groups["name"].Value)); + XElement reference = Assert.Single(project.Descendants("PackageReference")); + Assert.Equal(("CheatEngine.SDK", NeighbourPluginSource.SdkVersion), + ((string?) reference.Attribute("Include"), (string?) reference.Attribute("Version"))); + Assert.Equal("false", project.Descendants("RestorePackagesWithLockFile").Single().Value); + Assert.Equal("x64", project.Descendants("PlatformTarget").Single().Value); + Assert.Empty(project.Descendants("ProjectReference")); + } + + [Fact] + public void TheAnswerCarriesBooleansOnly() + { + string source = NeighbourPluginSource.Source(BridgeSha256); + const string Answer = "Sdk1Neighbour=Answering; SdkHostingMajorIs1=True; BridgeMatchesPackage=False; " + + "OwnLoadContextIsNotDefault=True; SdkHostingMajor2LoadedElsewhere=True"; + + string[] fields = [.. AnswerField().Matches(source).Select(static match => match.Groups["name"].Value)]; + Assert.Equal(["SdkHostingMajorIs1", "BridgeMatchesPackage", "OwnLoadContextIsNotDefault", "SdkHostingMajor2LoadedElsewhere"], + fields); + IReadOnlyDictionary parsed = NeighbourPluginSource.ParseIdentity(Answer); + Assert.Equal(fields.Order(StringComparer.Ordinal), parsed.Keys.Order(StringComparer.Ordinal)); + Assert.False(parsed["BridgeMatchesPackage"]); + Assert.True(parsed["SdkHostingMajorIs1"]); + Assert.Empty(NeighbourPluginSource.ParseIdentity("Plugin=A; SdkHostingMajorIs1=True")); + } + + [Theory] + [InlineData("")] + [InlineData("0123")] + [InlineData("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")] + [InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\"")] + public void OnlyALowerCaseSha256IsEmbedded(string hash) + { + Assert.Throws(() => NeighbourPluginSource.Source(hash)); + } + + [GeneratedRegex("""\[LuaFunction\("(?[^"]+)"\)\]""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex LuaFunction(); + + [GeneratedRegex(@"^using (?[A-Za-z.]+);", RegexOptions.CultureInvariant | RegexOptions.Multiline, 1000)] + private static partial Regex UsingDirective(); + + [GeneratedRegex("""; (?[A-Za-z0-9]+)=" \+""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex AnswerField(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/PluginBundleBuilder.cs b/tests/CheatEngine.Client.Tests/LiveQualification/PluginBundleBuilder.cs new file mode 100644 index 0000000..a276ff1 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/PluginBundleBuilder.cs @@ -0,0 +1,298 @@ +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Tests.Infrastructure; +using CheatEngine.Client.Tests.Packaging; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// A plugin deployment folder, built from the packed packages, that Cheat Engine loads with loadPlugin. +/// The bundle name, also its folder name below <run>/plugins. +/// The deployment folder (the complete closure the Hosting targets produce). +/// The plugin assembly to load. +/// The lower-case SHA-256 of the deployed native bridge. +internal sealed record PluginBundle(string Name, string Directory, string EntryAssemblyPath, string BridgeSha256); + +/// The instantiated template's bundle, with the facts S6 checks (Q40). +/// The deployment folder. +/// The Lua global the instantiated template exports. +/// +/// The CheatEngine.SDK content hash the template's restore recorded (its lock file, or project.assets.json +/// when the template writes no lock file), or . +/// +/// How many paths of the build workspace its deps.json holds. +internal sealed record TemplateBundle(PluginBundle Bundle, string StatusGlobal, string? SdkContentHash, int DepsWorkspacePaths); + +/// +/// Builds plugin bundles the way a plugin author does (Q40): the sources are copied into an isolated consumer outside +/// any repository, which references the packed CheatEngine.Client from the tested package directory and +/// CheatEngine.SDK from nuget.org (), and the Hosting deployment target writes +/// the complete closure to <run>/plugins/<name>. The workspace build is never loaded. The SDK 1.x +/// neighbour of S5 references nothing of the Client, and the template of S6 is instantiated from the packed Templates +/// package. +/// +[SupportedOSPlatform("windows")] +internal sealed partial class PluginBundleBuilder +{ + /// The bundle name of the qualification harness. + internal const string HarnessName = "harness"; + + /// The assembly name of the qualification harness, as its repository project declares it. + internal const string HarnessAssemblyName = "CheatEngine.Client.LivePlugin.Qualification"; + + /// The repository folder of the harness sources. + internal const string HarnessSourceFolder = "tests/CheatEngine.Client.LivePlugin.Qualification"; + + /// The repository folder of the coexistence fixtures. + internal const string CoexistenceSourceFolder = "tests/CheatEngine.Client.LivePlugin.Coexistence"; + + /// The project name S6 instantiates the template as. + internal const string TemplateProjectName = "QualTemplatePlugin"; + + private readonly PackagedClientFeedFixture _feed; + private readonly string _pluginsDirectory; + + internal PluginBundleBuilder(PackagedClientFeedFixture feed, string pluginsDirectory) + { + ArgumentNullException.ThrowIfNull(feed); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory); + _feed = feed; + _pluginsDirectory = pluginsDirectory; + } + + /// The harness sources: the plugin folder's own C# files and its Harness/ folder. + internal static IReadOnlyList HarnessSources(string repositoryRoot) + { + string folder = Path.Combine(repositoryRoot, HarnessSourceFolder); + return Directory.EnumerateFiles(folder, "*.cs", SearchOption.TopDirectoryOnly) + .Concat(Directory.EnumerateFiles(Path.Combine(folder, "Harness"), "*.cs", SearchOption.TopDirectoryOnly)) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + /// The sources of one coexistence plugin: its folder's C# files and the shared diagnostics file. + internal static IReadOnlyList CoexistenceSources(string repositoryRoot, SessionBundle bundle) + { + string folder = Path.Combine(repositoryRoot, CoexistenceSourceFolder); + return Directory.EnumerateFiles(Path.Combine(folder, CoexistenceFolder(bundle)), "*.cs", SearchOption.TopDirectoryOnly) + .Append(Path.Combine(folder, "CoexistenceDiagnostics.cs")) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + /// The assembly name of a coexistence plugin, as its repository project declares it. + internal static string CoexistenceAssemblyName(SessionBundle bundle) + { + return "CheatEngine.Client.LivePlugin.Coexistence." + CoexistenceFolder(bundle); + } + + /// Builds the qualification harness from the packed packages. + internal Task BuildHarnessAsync() + { + string root = RepositoryLayout.Combine(HarnessSourceFolder); + return BuildClientConsumerAsync(HarnessName, HarnessAssemblyName, "LivePlugin.Qualification", + HarnessSources(RepositoryLayout.Root).Select(source => (Path.GetRelativePath(root, source), source))); + } + + /// Builds one coexistence plugin (A, B or the collision contender) from the packed packages. + internal Task BuildCoexistenceAsync(SessionBundle bundle) + { + string folder = CoexistenceFolder(bundle); + return BuildClientConsumerAsync(folder.ToLowerInvariant(), CoexistenceAssemblyName(bundle), + "LivePlugin.Coexistence." + folder, + CoexistenceSources(RepositoryLayout.Root, bundle).Select(static source => (Path.GetFileName(source), source))); + } + + /// + /// Builds the plain CheatEngine.SDK 1.x neighbour of S5: restored first, so that the SHA-256 of the bridge its + /// package ships is measured before the source that compares with it is written. + /// + internal async Task BuildNeighbourAsync() + { + _feed.RequirePackages(); + Assert.True(_feed.UsesPinnedSdk, + $"The SDK 1.x neighbour restores CheatEngine.SDK {NeighbourPluginSource.SdkVersion} from nuget.org; unset " + + $"{PackagedClientFeedFixture.SdkPackageSourceVariable}."); + const string Name = "sdk1-neighbour"; + string consumer = _feed.CreateDirectory("bundle-" + Name); + PackagedClientFeedFixture.AssertOutsideAnyRepository(consumer); + string project = Path.Combine(consumer, NeighbourPluginSource.AssemblyName + ".csproj"); + await File.WriteAllTextAsync(project, NeighbourPluginSource.Project(), new UTF8Encoding(false)); + await RunAsync(consumer, "restore", project, "--configfile", _feed.NuGetConfiguration, "--packages", _feed.PackageCache); + + string package = Path.Combine(_feed.PackageCache, "cheatengine.sdk", NeighbourPluginSource.SdkVersion); + string packagedBridge = Assert.Single(Directory.EnumerateFiles(package, PackagedClientFeedFixture.BridgeFileName, + SearchOption.AllDirectories)); + await File.WriteAllTextAsync(Path.Combine(consumer, "NeighbourPlugin.cs"), + NeighbourPluginSource.Source(CheatEngineInstallation.Sha256(packagedBridge).ToLowerInvariant()), new UTF8Encoding(false)); + + string output = Path.Combine(_pluginsDirectory, Name); + await RunAsync(consumer, "build", project, "--configuration", "Release", "--no-restore", "-p:UseSharedCompilation=false", + "--output", output); + return Bundle(Name, output, NeighbourPluginSource.AssemblyName); + } + + /// + /// Instantiates the packed Templates package as in an isolated template home, + /// restores and builds it with its deployment path, and reads the facts S6 checks. + /// + internal async Task BuildTemplateAsync() + { + _feed.RequirePackages(); + const string Name = "template"; + string home = _feed.CreateDirectory("bundle-" + Name); + PackagedClientFeedFixture.AssertOutsideAnyRepository(home); + Dictionary isolatedHome = new(StringComparer.Ordinal) + { + ["DOTNET_CLI_HOME"] = Path.Combine(home, "cli-home"), + ["DOTNET_NEW_HOME"] = Path.Combine(home, "template-engine") + }; + string consumer = Path.Combine(home, TemplateProjectName); + string project = Path.Combine(consumer, TemplateProjectName + ".csproj"); + string output = Path.Combine(_pluginsDirectory, Name); + await RunAsync(home, isolatedHome, "new", "install", _feed.Package(PackagedClientFeedFixture.TemplatePackageId).Path); + await RunAsync(home, isolatedHome, "new", "ceplugin", "--name", TemplateProjectName, "--output", consumer); + await RunAsync(consumer, isolatedHome, "restore", project, "--configfile", _feed.NuGetConfiguration, "--packages", + _feed.PackageCache); + await RunAsync(consumer, isolatedHome, "build", project, "--configuration", "Release", "--no-restore", + "-p:UseSharedCompilation=false", $"-p:CheatEnginePluginOutputPath={output}"); + + PluginBundle bundle = Bundle(Name, output, TemplateProjectName); + string sources = string.Concat(Directory.EnumerateFiles(consumer, "*.cs", SearchOption.AllDirectories) + .Where(static file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Select(File.ReadAllText)); + string statusGlobal = LuaFunctionName().Match(sources) is { Success: true } match + ? match.Groups["name"].Value + : throw new InvalidOperationException("The instantiated template declares no [LuaFunction]."); + string deps = await File.ReadAllTextAsync(Path.Combine(output, TemplateProjectName + ".deps.json")); + return new TemplateBundle(bundle, statusGlobal, SdkContentHash(consumer), + CountPaths(deps, [consumer, home, RepositoryLayout.Root])); + } + + /// How many times names one of , with either slash, JSON-escaped or not. + internal static int CountPaths(string text, IEnumerable paths) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(paths); + int count = 0; + foreach (string path in paths.Select(static path => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)))) + { + foreach (string form in new HashSet( + [path, path.Replace('\\', '/'), path.Replace("\\", "\\\\", StringComparison.Ordinal)], + StringComparer.OrdinalIgnoreCase)) + { + for (int index = text.IndexOf(form, StringComparison.OrdinalIgnoreCase); index >= 0; + index = text.IndexOf(form, index + form.Length, StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + } + + return count; + } + + /// The CheatEngine.SDK content hash a restore recorded: the lock file, else obj/project.assets.json. + internal static string? SdkContentHash(string projectDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectDirectory); + string lockFile = Path.Combine(projectDirectory, "packages.lock.json"); + if (File.Exists(lockFile)) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(lockFile)); + foreach (JsonProperty framework in document.RootElement.GetProperty("dependencies").EnumerateObject()) + { + if (framework.Value.TryGetProperty(PackagedClientFeedFixture.SdkPackageId, out JsonElement sdk) && + sdk.TryGetProperty("contentHash", out JsonElement hash)) + { + return hash.GetString(); + } + } + } + + string assets = Path.Combine(projectDirectory, "obj", "project.assets.json"); + if (!File.Exists(assets)) + { + return null; + } + + using JsonDocument restore = JsonDocument.Parse(File.ReadAllText(assets)); + foreach (JsonProperty library in restore.RootElement.GetProperty("libraries").EnumerateObject()) + { + if (library.Name.StartsWith(PackagedClientFeedFixture.SdkPackageId + "/", StringComparison.OrdinalIgnoreCase) && + library.Value.TryGetProperty("sha512", out JsonElement sha512)) + { + return sha512.GetString(); + } + } + + return null; + } + + private static string CoexistenceFolder(SessionBundle bundle) + { + return bundle switch + { + SessionBundle.PluginA => "PluginA", + SessionBundle.PluginB => "PluginB", + SessionBundle.PluginCollision => "PluginCollision", + _ => throw new ArgumentOutOfRangeException(nameof(bundle), bundle, "Not a coexistence plugin.") + }; + } + + private async Task BuildClientConsumerAsync(string name, string assemblyName, string rootNamespace, + IEnumerable<(string Relative, string Source)> sources) + { + _feed.RequirePackages(); + string consumer = _feed.CreateDirectory("bundle-" + name); + PackagedClientFeedFixture.AssertOutsideAnyRepository(consumer); + foreach ((string relative, string source) in sources) + { + string copy = Path.Combine(consumer, relative); + Directory.CreateDirectory(Path.GetDirectoryName(copy)!); + File.Copy(source, copy); + } + + string project = Path.Combine(consumer, assemblyName + ".csproj"); + string properties = $""" + {assemblyName} + {rootNamespace} + true + true + """; + await File.WriteAllTextAsync(project, + PackagedClientFeedFixture.CreateConsumerProject(_feed.ClientVersion, _feed.SdkVersion, properties), + new UTF8Encoding(false)); + + string output = Path.Combine(_pluginsDirectory, name); + await RunAsync(consumer, "restore", project, "--configfile", _feed.NuGetConfiguration, "--packages", _feed.PackageCache); + await RunAsync(consumer, "build", project, "--configuration", "Release", "--no-restore", "-p:UseSharedCompilation=false", + $"-p:CheatEnginePluginOutputPath={output}"); + return Bundle(name, output, assemblyName); + } + + private static PluginBundle Bundle(string name, string output, string assemblyName) + { + string entry = Path.Combine(output, assemblyName + ".dll"); + string bridge = Path.Combine(output, PackagedClientFeedFixture.BridgeFileName); + Assert.True(File.Exists(entry), $"The {name} bundle has no '{Path.GetFileName(entry)}'."); + Assert.True(File.Exists(bridge), $"The {name} bundle has no '{PackagedClientFeedFixture.BridgeFileName}'."); + return new PluginBundle(name, output, entry, CheatEngineInstallation.Sha256(bridge).ToLowerInvariant()); + } + + private Task RunAsync(string workingDirectory, params string[] arguments) + { + return RunAsync(workingDirectory, new Dictionary(StringComparer.Ordinal), arguments); + } + + private async Task RunAsync(string workingDirectory, IReadOnlyDictionary overrides, params string[] arguments) + { + DotNetProcessResult result = await _feed.RunAsync(workingDirectory, overrides, arguments); + Assert.True(result.ExitCode == 0, result.ToString()); + } + + [GeneratedRegex("""\[LuaFunction\("(?[A-Za-z_][A-Za-z0-9_]*)"\)\]""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex LuaFunctionName(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/QualificationSummaryWriter.cs b/tests/CheatEngine.Client.Tests/LiveQualification/QualificationSummaryWriter.cs new file mode 100644 index 0000000..9c905e6 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/QualificationSummaryWriter.cs @@ -0,0 +1,270 @@ +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// How a session ended. +internal enum SessionOutcome +{ + /// The driver wrote DONE and Cheat Engine exited. + Completed, + + /// The session exceeded its timeout; the runner closed, then killed Cheat Engine. + TimedOut, + + /// The session could not run to its end (a preflight check, the build, or the host failed). + Failed +} + +/// A package the run consumed. +/// The package id. +/// The package version. +/// The lower-case SHA-256 of the package file. +internal sealed record PackageIdentity(string Id, string Version, string Sha256); + +/// A disposable target the run used. +/// The target file name. +/// The upper-case SHA-256 of the target image. +internal sealed record TargetIdentity(string Name, string Sha256); + +/// Everything a qualification result is bound to. +/// The packed Client packages the plugins were built from. +/// The repository commit the packages name. +/// The of the repository the run used. +/// The CheatEngine.SDK version the plugins referenced. +/// The source commit the CheatEngine.SDK package names. +/// The NuGet content hash of the CheatEngine.SDK package. +/// The SHA-256 of the native bridge deployed with the plugins. +/// The host profile id. +/// The host file version. +/// The host executable SHA-256. +/// The targets. +/// The .NET runtime the host is configured for. +/// The Windows build. +internal sealed record QualificationTuple( + IReadOnlyList ClientPackages, + string ClientCommit, + string QualifiedSourceDigest, + string SdkVersion, + string SdkCommit, + string SdkContentHash, + string SdkBridgeSha256, + string Profile, + string CheatEngineVersion, + string CheatEngineSha256, + IReadOnlyList Targets, + string DotNetRuntime, + string OperatingSystemBuild); + +/// One session of the run. +/// The session id. +/// How it ended. +/// Why, for any outcome other than . +internal sealed record SessionSummary(string Session, SessionOutcome Outcome, string Reason); + +/// The aggregated verdict of one scenario or capability. +/// The scenario or capability id. +/// Passed only when every receipt passed; Failed when one failed; NotExecuted otherwise. +/// The first failing or unexecuted check, or empty. +internal sealed record QualificationVerdict(string Id, ReceiptStatus Status, string Reason); + +/// The inputs of . +/// The run id. +/// What the result is bound to. +/// The sessions. +/// Every receipt of the run. +/// The scenarios each capability requires. +/// Whether the Cheat Engine user state was restored and verified after every session. +internal sealed record QualificationSummary( + string RunId, + QualificationTuple Tuple, + IReadOnlyList Sessions, + IReadOnlyList Receipts, + IReadOnlyDictionary> CapabilityScenarios, + bool RegistryRestored); + +/// +/// Writes summary.json (cheatengine-client-qualification-summary/v1): the tuple the result is bound to, +/// the verdict of every scenario and capability derived from the receipts, the sessions and whether the Cheat Engine +/// user state was restored. No pass is ever inferred: a scenario without receipts, or with an unexecuted check, is +/// NotExecuted. The text is redacted like a receipt and refused if it still discloses a path or a name. +/// +[SupportedOSPlatform("windows")] +internal static class QualificationSummaryWriter +{ + /// The summary schema. + internal const string Schema = "cheatengine-client-qualification-summary/v1"; + + private static readonly JsonWriterOptions WriterOptions = ReceiptLedger.WriterOptions with + { + Indented = true + }; + + /// The verdict of every scenario that has receipts, ordinally sorted. + internal static IReadOnlyList Scenarios(IReadOnlyList receipts) + { + ArgumentNullException.ThrowIfNull(receipts); + return receipts + .GroupBy(static receipt => receipt.Scenario, StringComparer.Ordinal) + .OrderBy(static group => group.Key, StringComparer.Ordinal) + .Select(static group => Verdict(group.Key, [.. group])) + .ToArray(); + } + + /// The verdict of every capability: Passed only when each of its scenarios passed. + internal static IReadOnlyList Capabilities(IReadOnlyDictionary> capabilityScenarios, + IReadOnlyList scenarios) + { + ArgumentNullException.ThrowIfNull(capabilityScenarios); + ArgumentNullException.ThrowIfNull(scenarios); + Dictionary byId = scenarios.ToDictionary(static verdict => verdict.Id, StringComparer.Ordinal); + List verdicts = []; + foreach ((string capability, IReadOnlyList required) in capabilityScenarios.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + QualificationVerdict[] parts = required + .Select(scenario => byId.TryGetValue(scenario, out QualificationVerdict? verdict) + ? verdict + : new QualificationVerdict(scenario, ReceiptStatus.NotExecuted, $"{scenario} has no receipt")) + .ToArray(); + QualificationVerdict? failed = parts.FirstOrDefault(static part => part.Status == ReceiptStatus.Failed); + QualificationVerdict? open = parts.FirstOrDefault(static part => part.Status == ReceiptStatus.NotExecuted); + if (failed is not null) + { + verdicts.Add(new QualificationVerdict(capability, ReceiptStatus.Failed, $"{failed.Id}: {failed.Reason}")); + } + else if (parts.Length == 0) + { + verdicts.Add(new QualificationVerdict(capability, ReceiptStatus.NotExecuted, "no scenario is mapped")); + } + else if (open is not null) + { + verdicts.Add(new QualificationVerdict(capability, ReceiptStatus.NotExecuted, $"{open.Id}: {open.Reason}")); + } + else + { + verdicts.Add(new QualificationVerdict(capability, ReceiptStatus.Passed, string.Empty)); + } + } + + return verdicts; + } + + /// The summary as indented JSON, redacted and checked. + internal static string Serialize(QualificationSummary summary, QualificationRedaction redaction) + { + ArgumentNullException.ThrowIfNull(summary); + ArgumentNullException.ThrowIfNull(redaction); + IReadOnlyList scenarios = Scenarios(summary.Receipts); + using MemoryStream buffer = new(); + using (Utf8JsonWriter json = new(buffer, WriterOptions)) + { + json.WriteStartObject(); + json.WriteString("schema", Schema); + json.WriteString("runId", summary.RunId); + WriteTuple(json, summary.Tuple); + json.WriteStartArray("sessions"); + foreach (SessionSummary session in summary.Sessions) + { + json.WriteStartObject(); + json.WriteString("session", session.Session); + json.WriteString("outcome", session.Outcome.ToString()); + json.WriteString("reason", session.Reason); + json.WriteEndObject(); + } + + json.WriteEndArray(); + WriteVerdicts(json, "scenarios", scenarios); + WriteVerdicts(json, "capabilities", Capabilities(summary.CapabilityScenarios, scenarios)); + json.WriteBoolean("registryRestored", summary.RegistryRestored); + json.WriteEndObject(); + } + + string text = redaction.Redact(Encoding.UTF8.GetString(buffer.ToArray())); + IReadOnlyList disclosures = redaction.FindDisclosures(text); + if (disclosures.Count > 0) + { + throw new InvalidOperationException($"The summary still discloses {string.Join(" and ", disclosures)}; redact it before writing."); + } + + return text; + } + + /// Writes the summary to . + internal static void Write(string path, QualificationSummary summary, QualificationRedaction redaction) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + File.WriteAllText(path, Serialize(summary, redaction) + "\n", new UTF8Encoding(false)); + } + + private static QualificationVerdict Verdict(string scenario, QualificationReceipt[] receipts) + { + QualificationReceipt? failed = receipts.FirstOrDefault(static receipt => receipt.Status == ReceiptStatus.Failed); + if (failed is not null) + { + return new QualificationVerdict(scenario, ReceiptStatus.Failed, failed.Check); + } + + QualificationReceipt? open = receipts.FirstOrDefault(static receipt => receipt.Status == ReceiptStatus.NotExecuted); + return open is not null + ? new QualificationVerdict(scenario, ReceiptStatus.NotExecuted, open.Check) + : new QualificationVerdict(scenario, ReceiptStatus.Passed, string.Empty); + } + + private static void WriteTuple(Utf8JsonWriter json, QualificationTuple tuple) + { + json.WriteStartObject("tuple"); + json.WriteStartArray("clientPackages"); + foreach (PackageIdentity package in tuple.ClientPackages.OrderBy(static package => package.Id, StringComparer.Ordinal)) + { + json.WriteStartObject(); + json.WriteString("id", package.Id); + json.WriteString("version", package.Version); + json.WriteString("sha256", package.Sha256); + json.WriteEndObject(); + } + + json.WriteEndArray(); + json.WriteString("clientCommit", tuple.ClientCommit); + json.WriteString("qualifiedSourceDigest", tuple.QualifiedSourceDigest); + json.WriteStartObject("sdk"); + json.WriteString("version", tuple.SdkVersion); + json.WriteString("commit", tuple.SdkCommit); + json.WriteString("contentHash", tuple.SdkContentHash); + json.WriteString("bridgeSha256", tuple.SdkBridgeSha256); + json.WriteEndObject(); + json.WriteString("profile", tuple.Profile); + json.WriteStartObject("cheatEngine"); + json.WriteString("version", tuple.CheatEngineVersion); + json.WriteString("sha256", tuple.CheatEngineSha256); + json.WriteEndObject(); + json.WriteStartArray("targets"); + foreach (TargetIdentity target in tuple.Targets.OrderBy(static target => target.Name, StringComparer.Ordinal)) + { + json.WriteStartObject(); + json.WriteString("name", target.Name); + json.WriteString("sha256", target.Sha256); + json.WriteEndObject(); + } + + json.WriteEndArray(); + json.WriteString("dotnetRuntime", tuple.DotNetRuntime); + json.WriteString("osBuild", tuple.OperatingSystemBuild); + json.WriteEndObject(); + } + + private static void WriteVerdicts(Utf8JsonWriter json, string name, IReadOnlyList verdicts) + { + json.WriteStartArray(name); + foreach (QualificationVerdict verdict in verdicts) + { + json.WriteStartObject(); + json.WriteString("id", verdict.Id); + json.WriteString("status", verdict.Status.ToString()); + json.WriteString("reason", verdict.Reason); + json.WriteEndObject(); + } + + json.WriteEndArray(); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/QualificationSummaryWriterTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/QualificationSummaryWriterTests.cs new file mode 100644 index 0000000..6ebb7fe --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/QualificationSummaryWriterTests.cs @@ -0,0 +1,116 @@ +using System.Runtime.Versioning; +using System.Text.Json; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The run summary (cheatengine-client-qualification-summary/v1): verdicts are derived from the receipts and +/// never invented, a capability passes only when all its scenarios passed, and the text is redacted like a receipt. +/// +[SupportedOSPlatform("windows")] +public sealed class QualificationSummaryWriterTests +{ + private const string RunDirectory = @"C:\runs\20260924T101530Z-a1b2"; + + private static readonly QualificationReceipt[] Receipts = + [ + Receipt("Q05", "identity", ReceiptStatus.Passed), + Receipt("Q05", "epoch", ReceiptStatus.Passed), + Receipt("Q06", "rollback", ReceiptStatus.Passed), + Receipt("Q06", "reenable", ReceiptStatus.Failed), + Receipt("Q43", "cleanup", ReceiptStatus.Passed), + Receipt("Q43", "operator-toggle", ReceiptStatus.NotExecuted) + ]; + + [Fact] + public void AScenarioPassesOnlyWhenEveryCheckPassed() + { + Assert.Equal( + [ + new QualificationVerdict("Q05", ReceiptStatus.Passed, string.Empty), + new QualificationVerdict("Q06", ReceiptStatus.Failed, "reenable"), + new QualificationVerdict("Q43", ReceiptStatus.NotExecuted, "operator-toggle") + ], QualificationSummaryWriter.Scenarios([.. Enumerable.Reverse(Receipts)])); + } + + [Fact] + public void ACapabilityPassesOnlyWhenEveryScenarioPassed() + { + Dictionary> mapping = new(StringComparer.Ordinal) + { + ["Client.Unmapped"] = [], + ["Client.Lifecycle"] = ["Q05", "Q06"], + ["Client.Identity"] = ["Q05"], + ["Client.Cleanup"] = ["Q05", "Q43"], + ["Client.Missing"] = ["Q05", "Q99"] + }; + + IReadOnlyList verdicts = QualificationSummaryWriter.Capabilities(mapping, + QualificationSummaryWriter.Scenarios(Receipts)); + + Assert.Equal( + [ + new QualificationVerdict("Client.Cleanup", ReceiptStatus.NotExecuted, "Q43: operator-toggle"), + new QualificationVerdict("Client.Identity", ReceiptStatus.Passed, string.Empty), + new QualificationVerdict("Client.Lifecycle", ReceiptStatus.Failed, "Q06: reenable"), + new QualificationVerdict("Client.Missing", ReceiptStatus.NotExecuted, "Q99: Q99 has no receipt"), + new QualificationVerdict("Client.Unmapped", ReceiptStatus.NotExecuted, "no scenario is mapped") + ], verdicts); + } + + [Fact] + public void TheSummaryCarriesTheTupleVerdictsSessionsAndRegistryRestoration() + { + string text = QualificationSummaryWriter.Serialize(Summary(Tuple(RunDirectory + @"\ce\ce.runtimeconfig.json")), Redaction()); + + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + Assert.Equal(QualificationSummaryWriter.Schema, root.GetProperty("schema").GetString()); + Assert.Equal("20260924T101530Z-a1b2", root.GetProperty("runId").GetString()); + JsonElement tuple = root.GetProperty("tuple"); + Assert.Equal(["CheatEngine.Client", "CheatEngine.Client.Core"], + tuple.GetProperty("clientPackages").EnumerateArray().Select(static package => package.GetProperty("id").GetString())); + Assert.Equal(new string('d', 64), tuple.GetProperty("qualifiedSourceDigest").GetString()); + Assert.Equal("b008c8d8", tuple.GetProperty("sdk").GetProperty("bridgeSha256").GetString()); + Assert.Equal("ce-7.7.0.10621-x64-managed-hostfxr", tuple.GetProperty("profile").GetString()); + Assert.Equal("\\ce\\ce.runtimeconfig.json", tuple.GetProperty("dotnetRuntime").GetString()); + Assert.Equal("TimedOut", root.GetProperty("sessions")[0].GetProperty("outcome").GetString()); + Assert.Equal(["Q05", "Q06", "Q43"], root.GetProperty("scenarios").EnumerateArray().Select(static verdict => verdict.GetProperty("id").GetString())); + Assert.Equal("Passed", root.GetProperty("capabilities")[0].GetProperty("status").GetString()); + Assert.True(root.GetProperty("registryRestored").GetBoolean()); + } + + [Fact] + public void ASummaryThatStillDisclosesAPathIsRefused() + { + InvalidOperationException refused = Assert.Throws(() => + QualificationSummaryWriter.Serialize(Summary(Tuple(@"D:\elsewhere\ce.runtimeconfig.json")), Redaction())); + + Assert.Contains("a local path", refused.Message, StringComparison.Ordinal); + Assert.DoesNotContain("elsewhere", refused.Message, StringComparison.Ordinal); + } + + private static QualificationRedaction Redaction() + { + return new QualificationRedaction(RunDirectory, ["jdoe"]); + } + + private static QualificationSummary Summary(QualificationTuple tuple) + { + return new QualificationSummary("20260924T101530Z-a1b2", tuple, [new SessionSummary("S1", SessionOutcome.TimedOut, "host TimedOut")], + Receipts, new Dictionary>(StringComparer.Ordinal) { ["Client.Identity"] = ["Q05"] }, true); + } + + private static QualificationTuple Tuple(string runtime) + { + return new QualificationTuple( + [new PackageIdentity("CheatEngine.Client.Core", "1.0.0", "cc"), new PackageIdentity("CheatEngine.Client", "1.0.0", "aa")], + "0123456789abcdef", new string('d', 64), "2.0.0", "325c47b", "NLEdZ", "b008c8d8", "ce-7.7.0.10621-x64-managed-hostfxr", "7.7.0.10621", + "9727076D", [new TargetIdentity("gtutorial-x86_64.exe", "2DABEFFD")], runtime, "10.0.26200.0"); + } + + private static QualificationReceipt Receipt(string scenario, string check, ReceiptStatus status) + { + return new QualificationReceipt("20260924T101530Z-a1b2", "S1", scenario, check, "C3", status, "expected", "observed"); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/QualifiedSourceDigest.cs b/tests/CheatEngine.Client.Tests/LiveQualification/QualifiedSourceDigest.cs new file mode 100644 index 0000000..e123813 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/QualifiedSourceDigest.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// One input of the digest: its repository-relative path and the SHA-256 of its normalized content. +/// The forward-slash path from the repository root. +/// The lower-case SHA-256 of the content with CRLF normalized to LF (binary files as is). +internal readonly record struct DigestInput(string Path, string Sha256); + +/// +/// The SHA-256 that binds qualification evidence to the shipping sources: every tracked file of libs/, +/// src/, source-generators/ and templates/ (their lock files included), plus +/// Directory.Build.*, Directory.Packages.props, eng/*.props and global.json, but no +/// Markdown (*.md, AnalyzerReleases.*.md included), no PublicAPI.*.txt and no +/// HostQualificationEvidence.cs, which records the evidence itself. Text is normalized from CRLF to LF, so a +/// checkout's line endings never change the digest. The digest is the SHA-256 of a sha256sum-style manifest, +/// one <sha256> <path>\n line per input in ordinal path order. Files are enumerated like +/// git ls-files sees the tree: build output and tool folders (bin, obj, artifacts, +/// .git, .idea, .vs, TestResults) and the other .gitignore patterns never count. +/// Compiled into CheatEngine.Client.Tests (the live runner records it) and CheatEngine.Client.Repository.Tests (the +/// evidence tests recompute it). +/// +internal static class QualifiedSourceDigest +{ + /// The folders whose every tracked file is an input. + internal static readonly string[] IncludedDirectories = ["libs/", "src/", "source-generators/", "templates/"]; + + /// The repository-root files that are inputs. + internal static readonly string[] IncludedRootFiles = + ["Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "global.json"]; + + /// The folder whose *.props files, directly inside it, are inputs. + internal const string IncludedPropsDirectory = "eng/"; + + /// The file name patterns that are never inputs. + internal static readonly string[] ExcludedFilePatterns = ["*.md", "PublicAPI.*.txt", "AnalyzerReleases.*.md", "HostQualificationEvidence.cs"]; + + /// Path segments that .gitignore excludes: build output and tool state. + internal static readonly string[] IgnoredSegments = ["bin", "obj", "artifacts", ".git", ".idea", ".vs", "TestResults"]; + + /// File extensions that .gitignore excludes. + internal static readonly string[] IgnoredExtensions = [".binlog", ".user", ".suo"]; + + /// The extensions .gitattributes marks binary: hashed as is, never normalized. + internal static readonly string[] BinaryExtensions = [".dll", ".exe", ".nupkg", ".snupkg", ".png", ".ico", ".snk"]; + + /// Whether a repository-relative, forward-slash path is an input of the digest. + internal static bool IsInput(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + string[] segments = relativePath.Split('/'); + string name = segments[^1]; + if (segments.Any(static segment => IgnoredSegments.Contains(segment, StringComparer.OrdinalIgnoreCase)) || + IgnoredExtensions.Any(extension => name.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) || + IsExcludedName(name)) + { + return false; + } + + return IncludedDirectories.Any(directory => relativePath.StartsWith(directory, StringComparison.Ordinal)) || + IncludedRootFiles.Contains(relativePath, StringComparer.Ordinal) || + (segments.Length == 2 && relativePath.StartsWith(IncludedPropsDirectory, StringComparison.Ordinal) && + name.EndsWith(".props", StringComparison.OrdinalIgnoreCase)); + } + + /// Every input below , as forward-slash relative paths in ordinal order. + internal static IReadOnlyList EnumerateInputs(string root) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + List inputs = []; + foreach (string directory in IncludedDirectories.Append(IncludedPropsDirectory)) + { + string folder = Path.Combine(root, directory); + if (!Directory.Exists(folder)) + { + continue; + } + + inputs.AddRange(Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories) + .Select(file => Path.GetRelativePath(root, file).Replace('\\', '/')) + .Where(IsInput)); + } + + inputs.AddRange(IncludedRootFiles.Where(file => File.Exists(Path.Combine(root, file)))); + inputs.Sort(StringComparer.Ordinal); + return inputs; + } + + /// Every input with the SHA-256 of its normalized content. + internal static IReadOnlyList Describe(string root) + { + return EnumerateInputs(root) + .Select(path => new DigestInput(path, Convert.ToHexStringLower(SHA256.HashData(Normalize(path, File.ReadAllBytes(Path.Combine(root, path))))))) + .ToArray(); + } + + /// The lower-case SHA-256 of the manifest of . + internal static string Compute(string root) + { + StringBuilder manifest = new(); + foreach (DigestInput input in Describe(root)) + { + manifest.Append(input.Sha256).Append(" ").Append(input.Path).Append('\n'); + } + + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(manifest.ToString()))); + } + + /// The content with every CRLF turned into LF, unless the path is binary. + internal static byte[] Normalize(string path, byte[] content) + { + ArgumentNullException.ThrowIfNull(path); + ArgumentNullException.ThrowIfNull(content); + if (BinaryExtensions.Any(extension => path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))) + { + return content; + } + + using MemoryStream normalized = new(content.Length); + for (int index = 0; index < content.Length; index++) + { + if (content[index] == (byte) '\r' && index + 1 < content.Length && content[index + 1] == (byte) '\n') + { + continue; + } + + normalized.WriteByte(content[index]); + } + + return normalized.ToArray(); + } + + private static bool IsExcludedName(string name) + { + return name.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || + (name.StartsWith("PublicAPI.", StringComparison.Ordinal) && name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) || + (name.StartsWith("AnalyzerReleases.", StringComparison.Ordinal) && name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) || + string.Equals(name, "HostQualificationEvidence.cs", StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/QualifiedSourceDigestTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/QualifiedSourceDigestTests.cs new file mode 100644 index 0000000..ebfa176 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/QualifiedSourceDigestTests.cs @@ -0,0 +1,195 @@ +using System.Text; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The shipping source digest: a CRLF checkout and an LF checkout hash the same, the input set is exactly the one the +/// qualification plan names, every content or path change moves the digest while excluded files never do, and the +/// enumeration agrees with what git tracks and ignores in this repository. +/// +public sealed class QualifiedSourceDigestTests : IDisposable +{ + private readonly TemporaryDirectory _temporary = new("QualifiedSourceDigest"); + + public void Dispose() + { + _temporary.Dispose(); + } + + [Fact] + public void TheInputAndExclusionListsAreThoseOfTheQualificationPlan() + { + Assert.Equal(["libs/", "src/", "source-generators/", "templates/"], QualifiedSourceDigest.IncludedDirectories); + Assert.Equal(["Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "global.json"], + QualifiedSourceDigest.IncludedRootFiles); + Assert.Equal("eng/", QualifiedSourceDigest.IncludedPropsDirectory); + Assert.Equal(["*.md", "PublicAPI.*.txt", "AnalyzerReleases.*.md", "HostQualificationEvidence.cs"], + QualifiedSourceDigest.ExcludedFilePatterns); + } + + [Fact] + public void TheInputSetIsExact() + { + string root = Tree("exact", "\r\n"); + + Assert.Equal( + [ + "Directory.Build.props", + "Directory.Build.targets", + "Directory.Packages.props", + "eng/CheatEngineSdk.props", + "eng/Shipping.props", + "global.json", + "libs/Core/Core.csproj", + "libs/Core/Domains/Runtime.cs", + "libs/Core/packages.lock.json", + "source-generators/Lua/Emitter.cs", + "src/Client/Client.csproj", + "templates/Templates/content/Plugin/.template.config/template.json", + "templates/Templates/content/Plugin/Plugin.cs", + "templates/Templates/packages.lock.json" + ], QualifiedSourceDigest.EnumerateInputs(root)); + } + + [Theory] + [InlineData("libs/Core/README.md", false)] + [InlineData("libs/Core/PublicAPI.Shipped.txt", false)] + [InlineData("libs/Core/PublicAPI.Unshipped.txt", false)] + [InlineData("source-generators/Lua/AnalyzerReleases.Unshipped.md", false)] + [InlineData("libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs", false)] + [InlineData("libs/Core/bin/Debug/Core.dll", false)] + [InlineData("libs/Core/obj/project.assets.json", false)] + [InlineData("libs/Core/build.binlog", false)] + [InlineData("libs/Core/Core.csproj.user", false)] + [InlineData("eng/nested/Other.props", false)] + [InlineData("eng/Build.targets", false)] + [InlineData("tests/Core.Tests/CoreTests.cs", false)] + [InlineData("README.md", false)] + [InlineData("nuget.config", false)] + [InlineData(".editorconfig", false)] + [InlineData("libs/Core/PublicAPI.txt.cs", true)] + [InlineData("libs/Core/Resources/icon.png", true)] + [InlineData("eng/Tests.props", true)] + public void EachPathIsClassifiedByThePlan(string path, bool input) + { + Assert.Equal(input, QualifiedSourceDigest.IsInput(path)); + } + + [Fact] + public void TheEmbeddedEvidenceFileExistsAndIsNoInputWhileTheGateIs() + { + const string Evidence = "libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs"; + IReadOnlyList inputs = QualifiedSourceDigest.EnumerateInputs(RepositoryLayout.Root); + + Assert.True(File.Exists(RepositoryLayout.Combine(Evidence)), $"'{Evidence}' is the file the digest excludes; it must exist."); + Assert.False(QualifiedSourceDigest.IsInput(Evidence)); + Assert.DoesNotContain(Evidence, inputs); + Assert.Contains("libs/CheatEngine.Client.Core/Qualification/HostQualificationGate.cs", inputs); + } + + [Fact] + public void CrLfAndLfCheckoutsHaveTheSameDigest() + { + string crlf = Tree("crlf", "\r\n"); + string lf = Tree("lf", "\n"); + + Assert.NotEqual(File.ReadAllBytes(Path.Combine(crlf, "global.json")), File.ReadAllBytes(Path.Combine(lf, "global.json"))); + Assert.Equal(QualifiedSourceDigest.Describe(lf), QualifiedSourceDigest.Describe(crlf)); + Assert.Equal(QualifiedSourceDigest.Compute(lf), QualifiedSourceDigest.Compute(crlf)); + Assert.Matches("^[0-9a-f]{64}$", QualifiedSourceDigest.Compute(lf)); + } + + [Fact] + public void ContentAndPathChangesMoveTheDigestButExcludedFilesNeverDo() + { + string root = Tree("changes", "\r\n"); + string original = QualifiedSourceDigest.Compute(root); + + File.WriteAllText(Path.Combine(root, "libs/Core/README.md"), "rewritten"); + File.WriteAllText(Path.Combine(root, "libs/Core/PublicAPI.Unshipped.txt"), "#nullable enable\r\nNew.Member\r\n"); + File.WriteAllText(Path.Combine(root, "libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs"), "// evidence"); + File.WriteAllText(Path.Combine(root, "tests/Core.Tests/CoreTests.cs"), "// changed test"); + Assert.Equal(original, QualifiedSourceDigest.Compute(root)); + + File.WriteAllText(Path.Combine(root, "libs/Core/Domains/Runtime.cs"), "class Runtime { }\r\n// changed\r\n"); + string changed = QualifiedSourceDigest.Compute(root); + File.Move(Path.Combine(root, "src/Client/Client.csproj"), Path.Combine(root, "src/Client/Renamed.csproj")); + string renamed = QualifiedSourceDigest.Compute(root); + File.WriteAllText(Path.Combine(root, "libs/Core/packages.lock.json"), "{ \"version\": 2, \"changed\": true }"); + + Assert.Equal(4, new[] { original, changed, renamed, QualifiedSourceDigest.Compute(root) }.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void BinaryFilesAreHashedAsTheyAre() + { + byte[] content = [0x89, (byte) 'P', (byte) 'N', (byte) 'G', (byte) '\r', (byte) '\n', 0x1A, (byte) '\n']; + + Assert.Equal(content, QualifiedSourceDigest.Normalize("libs/Core/icon.png", content)); + Assert.Equal([0x89, (byte) 'P', (byte) 'N', (byte) 'G', (byte) '\n', 0x1A, (byte) '\n'], + QualifiedSourceDigest.Normalize("libs/Core/data.bin", content)); + Assert.Equal("a\rb\nc\n"u8.ToArray(), QualifiedSourceDigest.Normalize("libs/Core/Text.cs", "a\rb\r\nc\n"u8.ToArray())); + } + + [Fact] + public async Task TheRepositoryEnumerationAgreesWithGitAsync() + { + string root = RepositoryLayout.Root; + string[] scope = + [ + .. QualifiedSourceDigest.IncludedDirectories.Select(static directory => directory.TrimEnd('/')), + QualifiedSourceDigest.IncludedPropsDirectory.TrimEnd('/'), + .. QualifiedSourceDigest.IncludedRootFiles + ]; + HashSet inputs = new(QualifiedSourceDigest.EnumerateInputs(root), StringComparer.Ordinal); + + string[] tracked = await GitFilesAsync(root, ["ls-files", "-z", "--", .. scope]); + string[] ignored = await GitFilesAsync(root, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", .. scope]); + + string[] missing = [.. tracked.Where(QualifiedSourceDigest.IsInput).Where(path => !inputs.Contains(path))]; + string[] included = [.. ignored.Where(inputs.Contains)]; + Assert.True(tracked.Length > 100, $"git ls-files listed only {tracked.Length} files below the digest scope."); + Assert.True(missing.Length == 0, $"Tracked inputs the digest does not enumerate: {string.Join(", ", missing)}"); + Assert.True(included.Length == 0, $"Files git ignores that the digest would hash: {string.Join(", ", included)}"); + Assert.Contains("eng/CheatEngineSdk.props", inputs); + Assert.Contains("templates/CheatEngine.Client.Templates/packages.lock.json", inputs); + Assert.DoesNotContain(inputs, static path => path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)); + } + + private static async Task GitFilesAsync(string root, string[] arguments) + { + DotNetProcessResult git = await DotNetProcess.RunToolAsync("git", root, new Dictionary(StringComparer.Ordinal), + arguments); + Assert.True(git.ExitCode == 0, git.ToString()); + return git.StandardOutput.Split('\0', StringSplitOptions.RemoveEmptyEntries); + } + + /// A fake repository whose text files use . + private string Tree(string name, string newLine) + { + string root = _temporary.CreateDirectory(name); + string[] files = + [ + "Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "global.json", "README.md", + "CHANGELOG.md", "nuget.config", ".editorconfig", "eng/CheatEngineSdk.props", "eng/Shipping.props", + "eng/nested/Other.props", "eng/Build.targets", "libs/Core/Core.csproj", "libs/Core/Domains/Runtime.cs", + "libs/Core/packages.lock.json", "libs/Core/README.md", "libs/Core/PublicAPI.Shipped.txt", + "libs/Core/PublicAPI.Unshipped.txt", "libs/Core/bin/Debug/Core.dll", "libs/Core/obj/project.assets.json", + "libs/Core/build.binlog", "libs/CheatEngine.Client.Core/Qualification/HostQualificationEvidence.cs", + "source-generators/Lua/Emitter.cs", "source-generators/Lua/AnalyzerReleases.Shipped.md", "src/Client/Client.csproj", + "templates/Templates/packages.lock.json", "templates/Templates/content/Plugin/Plugin.cs", + "templates/Templates/content/Plugin/.template.config/template.json", "tests/Core.Tests/CoreTests.cs", + "artifacts/bin/Core/Core.dll" + ]; + foreach (string file in files) + { + string path = Path.Combine(root, file); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, $"// {file}{newLine}line two{newLine}", new UTF8Encoding(false)); + } + + return root; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/ReceiptLedger.cs b/tests/CheatEngine.Client.Tests/LiveQualification/ReceiptLedger.cs new file mode 100644 index 0000000..4da1388 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/ReceiptLedger.cs @@ -0,0 +1,254 @@ +using System.Runtime.Versioning; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// The verdict of one qualification check. +internal enum ReceiptStatus +{ + /// The observation met the expectation. + Passed, + + /// The observation contradicts the expectation. + Failed, + + /// The check did not run, or ran without a conclusive observation. It never counts as a pass. + NotExecuted +} + +/// One redacted receipt line. +/// The run id. +/// The session id (S0 to S6). +/// The scenario id, for example Q05 or S0. +/// The check name within the scenario. +/// The qualification level: C3 (exact host) or C4 (host plus a second plugin). +/// The verdict. +/// What the check requires. +/// What the run observed, redacted. +internal sealed record QualificationReceipt( + string RunId, + string Session, + string Scenario, + string Check, + string Level, + ReceiptStatus Status, + string Expectation, + string Observation); + +/// +/// Redaction of everything a run writes as evidence: the run directory becomes <run>, and any remaining +/// local path, user name or machine name is refused rather than silently rewritten. +/// +[SupportedOSPlatform("windows")] +internal sealed partial class QualificationRedaction +{ + /// What the run directory is replaced with. + internal const string RunPlaceholder = ""; + + private readonly string? _runDirectory; + private readonly Regex[] _names; + + /// Creates a redaction for that refuses . + internal QualificationRedaction(string? runDirectory, IEnumerable identifyingNames) + { + ArgumentNullException.ThrowIfNull(identifyingNames); + _runDirectory = string.IsNullOrWhiteSpace(runDirectory) ? null : Path.TrimEndingDirectorySeparator(Path.GetFullPath(runDirectory)); + _names = identifyingNames + .Where(static name => name.Trim().Length >= 3) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(static name => new Regex($"(?The redaction of this workstation: the current user and machine names are refused. + internal static QualificationRedaction ForWorkstation(string? runDirectory) + { + return new QualificationRedaction(runDirectory, [Environment.UserName, Environment.MachineName]); + } + + /// Replaces the run directory (with either slash) by . + internal string Redact(string text) + { + ArgumentNullException.ThrowIfNull(text); + if (_runDirectory is null) + { + return text; + } + + string forward = _runDirectory.Replace('\\', '/'); + string escapedJson = _runDirectory.Replace("\\", "\\\\", StringComparison.Ordinal); + return text + .Replace(escapedJson, RunPlaceholder, StringComparison.OrdinalIgnoreCase) + .Replace(_runDirectory, RunPlaceholder, StringComparison.OrdinalIgnoreCase) + .Replace(forward, RunPlaceholder, StringComparison.OrdinalIgnoreCase); + } + + /// What still discloses; empty when it may be recorded. Never echoes the value. + internal IReadOnlyList FindDisclosures(string text) + { + ArgumentNullException.ThrowIfNull(text); + List disclosures = []; + if (LocalPath().IsMatch(text)) + { + disclosures.Add("a local path"); + } + + if (_names.Any(name => name.IsMatch(text))) + { + disclosures.Add("a user or machine name"); + } + + return disclosures; + } + + /// + /// A drive-rooted path, a UNC path (also JSON-escaped) that starts a word, a file: URI or a home-relative + /// path. A path below <run> is not one: the placeholder stands for the redacted run directory. + /// + [GeneratedRegex(@"(? +/// The append-only receipt ledger of a run (receipts.jsonl, one cheatengine-client-qualification-receipt/v1 +/// object per line). Every text field is redacted first; a receipt that still discloses a local path or a user name +/// is refused, so the ledger can be committed as evidence once the run is recorded. +/// +[SupportedOSPlatform("windows")] +internal sealed partial class ReceiptLedger +{ + /// The receipt schema. + internal const string Schema = "cheatengine-client-qualification-receipt/v1"; + + /// The qualification levels a receipt may state. + internal static readonly string[] Levels = ["C3", "C4"]; + + /// Keeps <run> and non-ASCII text readable; quotes and control characters are still escaped. + internal static readonly JsonWriterOptions WriterOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private readonly string _path; + private readonly QualificationRedaction _redaction; + + /// Creates a ledger that appends to . + internal ReceiptLedger(string path, QualificationRedaction redaction) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(redaction); + _path = path; + _redaction = redaction; + } + + /// Redacts, validates and appends one receipt; returns the receipt as recorded. + internal QualificationReceipt Append(QualificationReceipt receipt) + { + ArgumentNullException.ThrowIfNull(receipt); + QualificationReceipt redacted = receipt with + { + Expectation = _redaction.Redact(receipt.Expectation), + Observation = _redaction.Redact(receipt.Observation) + }; + Validate(redacted); + File.AppendAllText(_path, Serialize(redacted) + "\n", new UTF8Encoding(false)); + return redacted; + } + + /// Reads every receipt of a ledger. + internal static IReadOnlyList Read(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + List receipts = []; + if (!File.Exists(path)) + { + return receipts; + } + + foreach (string line in File.ReadLines(path).Where(static line => line.Length > 0)) + { + using JsonDocument document = JsonDocument.Parse(line); + JsonElement root = document.RootElement; + if (!string.Equals(root.GetProperty("schema").GetString(), Schema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"A receipt of '{Path.GetFileName(path)}' does not declare {Schema}."); + } + + receipts.Add(new QualificationReceipt(Text(root, "runId"), Text(root, "session"), Text(root, "scenario"), + Text(root, "check"), Text(root, "level"), Enum.Parse(Text(root, "status")), + Text(root, "expectation"), Text(root, "observation"))); + } + + return receipts; + } + + /// One receipt as a single JSON line, with a fixed property order. + internal static string Serialize(QualificationReceipt receipt) + { + ArgumentNullException.ThrowIfNull(receipt); + using MemoryStream buffer = new(); + using (Utf8JsonWriter json = new(buffer, WriterOptions)) + { + json.WriteStartObject(); + json.WriteString("schema", Schema); + json.WriteString("runId", receipt.RunId); + json.WriteString("session", receipt.Session); + json.WriteString("scenario", receipt.Scenario); + json.WriteString("check", receipt.Check); + json.WriteString("level", receipt.Level); + json.WriteString("status", receipt.Status.ToString()); + json.WriteString("expectation", receipt.Expectation); + json.WriteString("observation", receipt.Observation); + json.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + private void Validate(QualificationReceipt receipt) + { + string where = $"Receipt {receipt.Scenario}/{receipt.Check}"; + foreach ((string name, string value) in (ReadOnlySpan<(string, string)>) + [("runId", receipt.RunId), ("session", receipt.Session), ("scenario", receipt.Scenario), ("check", receipt.Check)]) + { + if (!Identifier().IsMatch(value)) + { + throw new ArgumentException($"{where}: '{name}' must be a short identifier.", nameof(receipt)); + } + } + + if (!Levels.Contains(receipt.Level, StringComparer.Ordinal)) + { + throw new ArgumentException($"{where}: the level must be one of {string.Join(", ", Levels)}.", nameof(receipt)); + } + + if (!Enum.IsDefined(receipt.Status)) + { + throw new ArgumentException($"{where}: the status is not a {nameof(ReceiptStatus)}.", nameof(receipt)); + } + + foreach ((string name, string value) in (ReadOnlySpan<(string, string)>) + [("expectation", receipt.Expectation), ("observation", receipt.Observation)]) + { + IReadOnlyList disclosures = _redaction.FindDisclosures(value); + if (disclosures.Count > 0) + { + throw new InvalidOperationException($"{where}: '{name}' still discloses {string.Join(" and ", disclosures)}; " + + "redact it before recording."); + } + } + } + + private static string Text(JsonElement root, string name) + { + return root.GetProperty(name).GetString() ?? string.Empty; + } + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z", RegexOptions.CultureInvariant, 1000)] + private static partial Regex Identifier(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/ReceiptLedgerTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/ReceiptLedgerTests.cs new file mode 100644 index 0000000..e697c7b --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/ReceiptLedgerTests.cs @@ -0,0 +1,122 @@ +using System.Runtime.Versioning; +using System.Text.Json; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The receipt ledger: one cheatengine-client-qualification-receipt/v1 JSON object per line, the run directory +/// redacted to <run>, and any other local path, user name or machine name refused without being echoed. +/// +[SupportedOSPlatform("windows")] +public sealed class ReceiptLedgerTests : IDisposable +{ + private readonly TemporaryDirectory _temporary = new("LiveQualificationReceipts"); + private readonly string _run; + private readonly string _path; + private readonly ReceiptLedger _ledger; + + public ReceiptLedgerTests() + { + _run = _temporary.CreateDirectory("20260924T101530Z-a1b2"); + _path = Path.Combine(_run, "receipts.jsonl"); + _ledger = new ReceiptLedger(_path, new QualificationRedaction(_run, ["jdoe", "WORKSTATION-7"])); + } + + public void Dispose() + { + _temporary.Dispose(); + } + + [Fact] + public void EveryReceiptIsOneSchemaLineWithAFixedPropertyOrder() + { + _ledger.Append(Receipt("status", ReceiptStatus.Passed, "{\"ok\":true}")); + _ledger.Append(Receipt("toggle-disable", ReceiptStatus.NotExecuted, "Operator: untick 'Plugin' ")); + + string[] lines = File.ReadAllText(_path).Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal(string.Empty, lines[2]); + using JsonDocument first = JsonDocument.Parse(lines[0]); + Assert.Equal(["schema", "runId", "session", "scenario", "check", "level", "status", "expectation", "observation"], + first.RootElement.EnumerateObject().Select(static property => property.Name)); + Assert.Equal(ReceiptLedger.Schema, first.RootElement.GetProperty("schema").GetString()); + Assert.Contains("\"observation\":\"Operator: untick 'Plugin' \"", lines[1], StringComparison.Ordinal); + Assert.Equal( + [ + Receipt("status", ReceiptStatus.Passed, "{\"ok\":true}"), + Receipt("toggle-disable", ReceiptStatus.NotExecuted, "Operator: untick 'Plugin' ") + ], ReceiptLedger.Read(_path)); + } + + [Fact] + public void TheRunDirectoryIsRedactedInEveryForm() + { + string forward = _run.Replace('\\', '/'); + string json = _run.Replace("\\", "\\\\", StringComparison.Ordinal); + + QualificationReceipt recorded = _ledger.Append(Receipt("paths", ReceiptStatus.Passed, + $"{_run}\\plugins; {forward}/ce; {{\"path\":\"{json}\\\\sessions\"}}")); + + Assert.Equal("\\plugins; /ce; {\"path\":\"\\\\sessions\"}", recorded.Observation); + } + + [Theory] + [InlineData(@"loaded C:\Windows\System32\kernel32.dll")] + [InlineData(@"C:/Program Files/Cheat Engine")] + [InlineData(@"share \\server\folder\file")] + [InlineData(@"{""share"":""\\\\server\\folder""}")] + [InlineData("file:///tmp/x")] + [InlineData(@"under ~\AppData")] + [InlineData("/home/someone/.nuget")] + [InlineData("C:\\\\Users\\\\x in JSON")] + public void ALocalPathIsRefusedAndNotEchoed(string observation) + { + InvalidOperationException refused = Assert.Throws(() => + _ledger.Append(Receipt("paths", ReceiptStatus.Passed, observation))); + + Assert.Contains("a local path", refused.Message, StringComparison.Ordinal); + Assert.DoesNotContain(observation, refused.Message, StringComparison.Ordinal); + Assert.False(File.Exists(_path)); + } + + [Theory] + [InlineData("run by jdoe")] + [InlineData("home of JDOE.")] + [InlineData("machine workstation-7 answered")] + public void AUserOrMachineNameIsRefused(string observation) + { + InvalidOperationException refused = Assert.Throws(() => + _ledger.Append(Receipt("names", ReceiptStatus.Passed, observation))); + + Assert.Contains("a user or machine name", refused.Message, StringComparison.Ordinal); + Assert.False(File.Exists(_path)); + } + + [Theory] + [InlineData("xjdoe")] + [InlineData("jdoes")] + [InlineData("https://github.com/CheatEngineNet/CheatEngine.Client")] + [InlineData("ratio 3:4, time 10:15")] + [InlineData(@"{""bundle"":""\\plugins\\harness\\bridge.dll""}")] + public void OrdinaryTextIsRecorded(string observation) + { + Assert.Equal(observation, _ledger.Append(Receipt("text", ReceiptStatus.Passed, observation)).Observation); + } + + [Fact] + public void InvalidIdentifiersAndLevelsAreRefused() + { + Assert.Throws(() => _ledger.Append(Receipt("text", ReceiptStatus.Passed, "x") with { Level = "C2" })); + Assert.Throws(() => _ledger.Append(Receipt("two words", ReceiptStatus.Passed, "x"))); + Assert.Throws(() => _ledger.Append(Receipt("check\n", ReceiptStatus.Passed, "x"))); + Assert.Throws(() => _ledger.Append(Receipt("text", (ReceiptStatus) 7, "x"))); + Assert.False(File.Exists(_path)); + } + + private static QualificationReceipt Receipt(string check, ReceiptStatus status, string observation) + { + return new QualificationReceipt("20260924T101530Z-a1b2", "S0", "S0", check, "C3", status, "the harness answers", observation); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/RegistryRecoveryTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/RegistryRecoveryTests.cs new file mode 100644 index 0000000..4a97860 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/RegistryRecoveryTests.cs @@ -0,0 +1,174 @@ +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Infrastructure; + +using Microsoft.Win32; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The user state guard around a session, on a test-owned scratch key and a temporary folder standing for +/// %APPDATA%\Cheat Engine (never the real ones): the backup and the crash marker come before any change, the +/// plugin list is neutralized for the session, the restore is verified before the marker goes, and a marker left by a +/// crashed run is restored first and fails the next run. +/// +[Collection(ScratchRegistrySerialGroup.Name)] +[SupportedOSPlatform("windows")] +public sealed class RegistryRecoveryTests : IDisposable +{ + private readonly ScratchRegistryKey _scratch = new(); + private readonly TemporaryDirectory _temporary = new("LiveQualificationRecovery"); + private readonly string _appData; + private readonly string _runRoot; + private readonly CheatEngineRegistryGuard _guard; + + public RegistryRecoveryTests() + { + _appData = Path.Combine(_temporary.Path, "Roaming", "Cheat Engine"); + _runRoot = _temporary.CreateDirectory("runs"); + _guard = new CheatEngineRegistryGuard(new CheatEngineUserStateLocations(_scratch.SubKey, _appData), ["Plugins64"], ["LastPlugin"]); + using (RegistryKey key = _scratch.Create()) + { + key.SetValue("Saved", "operator setting"); + key.SetValue("LastPlugin", "C:\\plugins\\mine.dll"); + } + + using (RegistryKey plugins = _scratch.Create(@"Plugins64\00")) + { + plugins.SetValue("Path", "mine.dll"); + } + + Directory.CreateDirectory(_appData); + File.WriteAllText(Path.Combine(_appData, "settings.txt"), "operator file"); + } + + public void Dispose() + { + _temporary.Dispose(); + _scratch.Dispose(); + } + + [Fact] + public void TheSessionSeesTheNeutralizedStateAndTheRestoreBringsBackEverything() + { + string registryBefore = RegistryText(); + SandboxLayout layout = NewRun(); + + using (ICheatEngineUserStateScope scope = _guard.Begin(layout)) + { + Assert.True(File.Exists(layout.RestoreMarkerPath)); + Assert.True(File.Exists(layout.RegistryBackupPath)); + Assert.True(File.Exists(Path.Combine(layout.AppDataBackupDirectory, "settings.txt"))); + Assert.DoesNotContain("Plugins64", RegistryText()); + Assert.DoesNotContain("LastPlugin", RegistryText()); + SimulateCheatEngine(); + + scope.Restore(); + + Assert.True(scope.Restored); + } + + Assert.Equal(registryBefore, RegistryText()); + Assert.Equal("operator file", File.ReadAllText(Path.Combine(_appData, "settings.txt"))); + Assert.False(File.Exists(Path.Combine(_appData, "created.txt"))); + Assert.False(File.Exists(layout.RestoreMarkerPath)); + } + + [Fact] + public void DisposingTheScopeRestoresWhenTheSessionThrows() + { + string registryBefore = RegistryText(); + SandboxLayout layout = NewRun(); + + Assert.Throws(Session); + + Assert.Equal(registryBefore, RegistryText()); + Assert.False(File.Exists(layout.RestoreMarkerPath)); + + void Session() + { + using ICheatEngineUserStateScope scope = _guard.Begin(layout); + SimulateCheatEngine(); + throw new TimeoutException("the session timed out"); + } + } + + [Fact] + public void AMarkerLeftByACrashedRunIsRestoredFirstAndFailsTheNextRun() + { + string registryBefore = RegistryText(); + SandboxLayout crashed = NewRun(); + _ = _guard.Begin(crashed); + SimulateCheatEngine(); + + InvalidOperationException stopped = Assert.Throws(() => _guard.Begin(NewRun())); + + Assert.Contains($"Run {crashed.RunId} ended without restoring", stopped.Message, StringComparison.Ordinal); + Assert.Contains("restored and verified", stopped.Message, StringComparison.Ordinal); + Assert.Equal(registryBefore, RegistryText()); + Assert.Equal("operator file", File.ReadAllText(Path.Combine(_appData, "settings.txt"))); + Assert.False(File.Exists(crashed.RestoreMarkerPath)); + using ICheatEngineUserStateScope next = _guard.Begin(NewRun()); + next.Restore(); + Assert.True(next.Restored); + } + + [Fact] + public void TheMarkerStaysUntilTheRestoreIsVerified() + { + SandboxLayout crashed = NewRun(); + _ = _guard.Begin(crashed); + SimulateCheatEngine(); + string backup = Path.Combine(crashed.AppDataBackupDirectory, "settings.txt"); + File.WriteAllText(backup, "corrupted backup"); + + InvalidOperationException failed = Assert.Throws(() => _guard.Begin(NewRun())); + + Assert.Contains("restoring its backup failed now", failed.Message, StringComparison.Ordinal); + Assert.True(File.Exists(crashed.RestoreMarkerPath)); + File.WriteAllText(backup, "operator file"); + Assert.Throws(() => _guard.Begin(NewRun())); + Assert.False(File.Exists(crashed.RestoreMarkerPath)); + Assert.Equal("operator file", File.ReadAllText(Path.Combine(_appData, "settings.txt"))); + } + + [Fact] + public void AnAppDataFolderTheSessionCreatedIsRemovedAgain() + { + Directory.Delete(_appData, true); + SandboxLayout layout = NewRun(); + + using (ICheatEngineUserStateScope scope = _guard.Begin(layout)) + { + Directory.CreateDirectory(_appData); + File.WriteAllText(Path.Combine(_appData, "created.txt"), "by Cheat Engine"); + } + + Assert.False(Directory.Exists(_appData)); + Assert.Contains("\"exists\": false", File.ReadAllText(layout.AppDataManifestPath), StringComparison.Ordinal); + } + + private SandboxLayout NewRun() + { + return SandboxLayout.Create(_runRoot, DateTimeOffset.UtcNow); + } + + private string RegistryText() + { + return RegistrySnapshot.Serialize(RegistrySnapshot.Capture(_scratch.SubKey)); + } + + /// What Cheat Engine might do to its user state during a session. + private void SimulateCheatEngine() + { + using (RegistryKey key = _scratch.Create()) + { + key.SetValue("Saved", "changed by Cheat Engine"); + key.SetValue("NewSetting", 1, RegistryValueKind.DWord); + } + + _scratch.Create(@"Plugins64\01").Dispose(); + File.WriteAllText(Path.Combine(_appData, "settings.txt"), "changed by Cheat Engine"); + File.WriteAllText(Path.Combine(_appData, "created.txt"), "by Cheat Engine"); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/RegistrySnapshot.cs b/tests/CheatEngine.Client.Tests/LiveQualification/RegistrySnapshot.cs new file mode 100644 index 0000000..676c6d4 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/RegistrySnapshot.cs @@ -0,0 +1,243 @@ +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; + +using Microsoft.Win32; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// One registry value, with its data in the type the registry returned it. +/// The value name (empty for the default value). +/// The registry type. +/// +/// A (String, ExpandString, unexpanded), a array (MultiString), an +/// (DWord), a (QWord) or a array (Binary, None). +/// +internal sealed record RegistryValueSnapshot(string Name, RegistryValueKind Kind, object Data); + +/// One registry key, with its values and subkeys sorted by name. +internal sealed record RegistryKeySnapshot(string Name, IReadOnlyList Values, IReadOnlyList Keys); + +/// A recursive snapshot of below HKEY_CURRENT_USER; is null when it is absent. +internal sealed record RegistryTreeSnapshot(string SubKey, RegistryKeySnapshot? Root) +{ + /// Whether the key existed. + internal bool Exists => Root is not null; +} + +/// +/// Takes, writes, reads and restores recursive snapshots of a key of HKEY_CURRENT_USER (schema +/// cheatengine-client-registry-backup/v1): every value name, type and raw data, and every subkey. A value of a +/// type the snapshot cannot restore exactly is refused, so the guard never changes a key it could not put back. +/// Restoring deletes the key tree, recreates it from the snapshot and proves the result equal to it. +/// +[SupportedOSPlatform("windows")] +internal static class RegistrySnapshot +{ + /// The backup schema. + internal const string Schema = "cheatengine-client-registry-backup/v1"; + + private static readonly JsonWriterOptions WriterOptions = new() + { + Indented = true + }; + + /// Captures of HKEY_CURRENT_USER. + internal static RegistryTreeSnapshot Capture(string subKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subKey); + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(subKey, false); + return new RegistryTreeSnapshot(subKey, key is null ? null : CaptureKey(key, string.Empty)); + } + + /// + /// Deletes , recreates it from the snapshot and verifies it. Only the Cheat + /// Engine key and the test scratch keys can be restored (). + /// + internal static void Restore(RegistryTreeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + CheatEngineUserStateLocations.RequireGuardedSubKey(snapshot.SubKey); + Registry.CurrentUser.DeleteSubKeyTree(snapshot.SubKey, false); + if (snapshot.Root is not null) + { + using RegistryKey key = Registry.CurrentUser.CreateSubKey(snapshot.SubKey, true); + Write(key, snapshot.Root); + } + + string expected = Serialize(snapshot); + if (!string.Equals(Serialize(Capture(snapshot.SubKey)), expected, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"HKEY_CURRENT_USER\\{snapshot.SubKey} differs from its backup after the restore."); + } + } + + /// The snapshot as indented JSON; two snapshots are equal exactly when their texts are. + internal static string Serialize(RegistryTreeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + using MemoryStream buffer = new(); + using (Utf8JsonWriter json = new(buffer, WriterOptions)) + { + json.WriteStartObject(); + json.WriteString("schema", Schema); + json.WriteString("key", "HKEY_CURRENT_USER\\" + snapshot.SubKey); + json.WriteBoolean("exists", snapshot.Exists); + if (snapshot.Root is not null) + { + json.WritePropertyName("root"); + WriteKey(json, snapshot.Root); + } + + json.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + /// Reads a snapshot written by . + internal static RegistryTreeSnapshot Parse(string text) + { + ArgumentNullException.ThrowIfNull(text); + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + if (!string.Equals(root.GetProperty("schema").GetString(), Schema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"The registry backup does not declare {Schema}."); + } + + string key = root.GetProperty("key").GetString() ?? string.Empty; + const string hive = "HKEY_CURRENT_USER\\"; + if (!key.StartsWith(hive, StringComparison.Ordinal)) + { + throw new InvalidDataException("The registry backup does not name a key of HKEY_CURRENT_USER."); + } + + return new RegistryTreeSnapshot(key[hive.Length..], + root.GetProperty("exists").GetBoolean() ? ReadKey(root.GetProperty("root"), string.Empty) : null); + } + + private static RegistryKeySnapshot CaptureKey(RegistryKey key, string name) + { + List values = []; + foreach (string valueName in key.GetValueNames().Order(StringComparer.OrdinalIgnoreCase)) + { + RegistryValueKind kind = key.GetValueKind(valueName); + object data = key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames) + ?? throw new InvalidOperationException($"The value '{valueName}' of {key.Name} vanished while it was read."); + values.Add(new RegistryValueSnapshot(valueName, kind, kind switch + { + RegistryValueKind.String or RegistryValueKind.ExpandString => (string) data, + RegistryValueKind.MultiString => (string[]) data, + RegistryValueKind.DWord => (int) data, + RegistryValueKind.QWord => (long) data, + RegistryValueKind.Binary or RegistryValueKind.None => (byte[]) data, + _ => throw new NotSupportedException( + $"The value '{valueName}' of {key.Name} has the type {kind}, which the guard cannot restore exactly; no session may start.") + })); + } + + List keys = []; + foreach (string child in key.GetSubKeyNames().Order(StringComparer.OrdinalIgnoreCase)) + { + using RegistryKey subKey = key.OpenSubKey(child, false) + ?? throw new InvalidOperationException($"The subkey '{child}' of {key.Name} vanished while it was read."); + keys.Add(CaptureKey(subKey, child)); + } + + return new RegistryKeySnapshot(name, values, keys); + } + + private static void Write(RegistryKey key, RegistryKeySnapshot snapshot) + { + foreach (RegistryValueSnapshot value in snapshot.Values) + { + key.SetValue(value.Name, value.Data, value.Kind); + } + + foreach (RegistryKeySnapshot child in snapshot.Keys) + { + using RegistryKey subKey = key.CreateSubKey(child.Name, true); + Write(subKey, child); + } + } + + private static void WriteKey(Utf8JsonWriter json, RegistryKeySnapshot key) + { + json.WriteStartObject(); + json.WriteString("name", key.Name); + json.WriteStartArray("values"); + foreach (RegistryValueSnapshot value in key.Values) + { + json.WriteStartObject(); + json.WriteString("name", value.Name); + json.WriteString("kind", value.Kind.ToString()); + json.WritePropertyName("data"); + switch (value.Data) + { + case string text: + json.WriteStringValue(text); + break; + case string[] lines: + json.WriteStartArray(); + foreach (string line in lines) + { + json.WriteStringValue(line); + } + + json.WriteEndArray(); + break; + case int number: + json.WriteNumberValue(number); + break; + case long number: + json.WriteNumberValue(number); + break; + case byte[] bytes: + json.WriteBase64StringValue(bytes); + break; + default: + throw new NotSupportedException($"Unexpected registry data for '{value.Name}'."); + } + + json.WriteEndObject(); + } + + json.WriteEndArray(); + json.WriteStartArray("keys"); + foreach (RegistryKeySnapshot child in key.Keys) + { + WriteKey(json, child); + } + + json.WriteEndArray(); + json.WriteEndObject(); + } + + private static RegistryKeySnapshot ReadKey(JsonElement element, string name) + { + List values = []; + foreach (JsonElement value in element.GetProperty("values").EnumerateArray()) + { + RegistryValueKind kind = Enum.Parse(value.GetProperty("kind").GetString()!); + JsonElement data = value.GetProperty("data"); + values.Add(new RegistryValueSnapshot(value.GetProperty("name").GetString()!, kind, kind switch + { + RegistryValueKind.String or RegistryValueKind.ExpandString => data.GetString()!, + RegistryValueKind.MultiString => data.EnumerateArray().Select(static line => line.GetString()!).ToArray(), + RegistryValueKind.DWord => data.GetInt32(), + RegistryValueKind.QWord => data.GetInt64(), + RegistryValueKind.Binary or RegistryValueKind.None => data.GetBytesFromBase64(), + _ => throw new InvalidDataException($"The registry backup holds the unsupported type {kind}.") + })); + } + + List keys = []; + foreach (JsonElement child in element.GetProperty("keys").EnumerateArray()) + { + keys.Add(ReadKey(child, child.GetProperty("name").GetString()!)); + } + + return new RegistryKeySnapshot(name, values, keys); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/RegistrySnapshotTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/RegistrySnapshotTests.cs new file mode 100644 index 0000000..2284047 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/RegistrySnapshotTests.cs @@ -0,0 +1,158 @@ +using System.Runtime.Versioning; + +using CheatEngine.Client.Tests.Infrastructure; + +using Microsoft.Win32; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The registry snapshot, on a test-owned scratch key only (HKCU\Software\CheatEngine.Client.Tests\<guid>, +/// removed afterwards with its parent when empty): every value type round-trips through the JSON backup, a restore +/// puts back exactly the captured tree, and the guard refuses any key but the Cheat Engine key and scratch keys. +/// +[Collection(ScratchRegistrySerialGroup.Name)] +[SupportedOSPlatform("windows")] +public sealed class RegistrySnapshotTests : IDisposable +{ + private readonly ScratchRegistryKey _scratch = new(); + + public void Dispose() + { + _scratch.Dispose(); + } + + [Fact] + public void EveryValueTypeRoundTripsThroughTheBackupAndTheRestore() + { + using (RegistryKey key = _scratch.Create()) + { + key.SetValue(string.Empty, "default value"); + key.SetValue("String", "text with \"quotes\" and é"); + key.SetValue("Expand", "%TEMP%\\cheat", RegistryValueKind.ExpandString); + key.SetValue("Multi", new[] { "first", string.Empty, "third" }, RegistryValueKind.MultiString); + key.SetValue("DWord", -2, RegistryValueKind.DWord); + key.SetValue("QWord", long.MinValue, RegistryValueKind.QWord); + key.SetValue("Binary", new byte[] { 0, 1, 254, 255 }, RegistryValueKind.Binary); + key.SetValue("None", new byte[] { 7 }, RegistryValueKind.None); + } + + using (RegistryKey plugins = _scratch.Create(@"Plugins64\00")) + { + plugins.SetValue("Path", "plugin.dll"); + } + + RegistryTreeSnapshot captured = RegistrySnapshot.Capture(_scratch.SubKey); + string text = RegistrySnapshot.Serialize(captured); + RegistryTreeSnapshot parsed = RegistrySnapshot.Parse(text); + using (RegistryKey key = _scratch.Create()) + { + key.SetValue("DWord", 5, RegistryValueKind.DWord); + key.DeleteValue("Binary"); + key.CreateSubKey("Added", false).Dispose(); + } + + RegistrySnapshot.Restore(parsed); + + Assert.Equal(text, RegistrySnapshot.Serialize(parsed)); + Assert.Equal(text, RegistrySnapshot.Serialize(RegistrySnapshot.Capture(_scratch.SubKey))); + using RegistryKey restored = Registry.CurrentUser.OpenSubKey(_scratch.SubKey, false)!; + Assert.Equal("%TEMP%\\cheat", restored.GetValue("Expand", null, RegistryValueOptions.DoNotExpandEnvironmentNames)); + Assert.Equal(RegistryValueKind.ExpandString, restored.GetValueKind("Expand")); + Assert.Equal(-2, restored.GetValue("DWord")); + Assert.Equal(new byte[] { 0, 1, 254, 255 }, restored.GetValue("Binary")); + Assert.Equal(["00"], restored.OpenSubKey("Plugins64")!.GetSubKeyNames()); + Assert.DoesNotContain("Added", restored.GetSubKeyNames()); + Assert.Contains("\"kind\": \"QWord\"", text, StringComparison.Ordinal); + Assert.Contains($"\"key\": \"HKEY_CURRENT_USER\\\\{_scratch.SubKey.Replace("\\", "\\\\", StringComparison.Ordinal)}\"", text, + StringComparison.Ordinal); + } + + [Fact] + public void AnAbsentKeyIsCapturedAsAbsentAndRestoredAsAbsent() + { + RegistryTreeSnapshot absent = RegistrySnapshot.Capture(_scratch.SubKey); + using (RegistryKey key = _scratch.Create("Created")) + { + key.SetValue("By", "the session"); + } + + RegistrySnapshot.Restore(RegistrySnapshot.Parse(RegistrySnapshot.Serialize(absent))); + + Assert.False(absent.Exists); + Assert.False(_scratch.Exists()); + } + + [Theory] + [InlineData(@"Software\Microsoft")] + [InlineData(@"Software")] + [InlineData(@"Software\CheatEngine.Client.Tests")] + [InlineData(@"Software\CheatEngine.Client.Tests\not-a-guid")] + [InlineData(@"Software\CheatEngine.Client.Tests\0123456789abcdef0123456789abcdef\child")] + [InlineData(@"Software\Cheat Engine\Plugins64")] + public void OnlyTheCheatEngineKeyAndScratchKeysAreGuarded(string subKey) + { + Assert.Throws(() => CheatEngineUserStateLocations.RequireGuardedSubKey(subKey)); + } + + [Fact] + public void ARestoreOfAnUnguardedKeyIsRefusedBeforeItDeletesAnything() + { + // A missing key below the scratch parent: even a broken check could not delete anything that exists. + RegistryTreeSnapshot unguarded = new(CheatEngineUserStateLocations.ScratchRegistryParent + @"\not-a-guid", null); + + Assert.Throws(() => RegistrySnapshot.Restore(unguarded)); + } + + [Fact] + public void TheGuardLocationsPairTheCheatEngineKeyWithAppDataAndScratchKeysWithTemp() + { + string temporary = Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "appdata"); + + CheatEngineUserStateLocations.Workstation.Validate(); + new CheatEngineUserStateLocations(_scratch.SubKey, temporary).Validate(); + Assert.Throws(() => new CheatEngineUserStateLocations(_scratch.SubKey, + CheatEngineUserStateLocations.Workstation.AppDataDirectory).Validate()); + Assert.Throws(() => new CheatEngineUserStateLocations(_scratch.SubKey, Path.GetTempPath()).Validate()); + Assert.Throws(() => new CheatEngineUserStateLocations(CheatEngineUserStateLocations.CheatEngineRegistrySubKey, + temporary).Validate()); + Assert.Equal(@"Software\Cheat Engine", CheatEngineUserStateLocations.Workstation.RegistrySubKey); + } + + [Fact] + public void TheScratchKeyAndItsEmptyParentAreRemoved() + { + ScratchRegistryKey scratch = new(); + scratch.Create("child").Dispose(); + + scratch.Dispose(); + + Assert.False(scratch.Exists()); + using RegistryKey? parent = Registry.CurrentUser.OpenSubKey(CheatEngineUserStateLocations.ScratchRegistryParent, false); + // Only this test's own key sits under the parent here (_scratch was never created), unless another test process + // holds one right now. + Assert.True(parent is null || parent.SubKeyCount > 0, + "The scratch parent HKCU\\Software\\CheatEngine.Client.Tests was left behind empty."); + } + + [Fact] + public void TheFolderBackupRestoresFilesAndFoldersByteForByte() + { + using TemporaryDirectory temporary = new("LiveQualificationAppData"); + string folder = Path.Combine(temporary.Path, "Cheat Engine"); + Directory.CreateDirectory(Path.Combine(folder, "tables", "empty")); + File.WriteAllText(Path.Combine(folder, "settings.txt"), "original"); + File.WriteAllBytes(Path.Combine(folder, "tables", "a.ct"), [1, 2, 3]); + + FileTreeSnapshot before = FileTreeBackup.Backup(folder, Path.Combine(temporary.Path, "backup")); + File.WriteAllText(Path.Combine(folder, "settings.txt"), "changed by the session"); + File.Delete(Path.Combine(folder, "tables", "a.ct")); + File.WriteAllText(Path.Combine(folder, "new.txt"), "added"); + FileTreeBackup.Restore(folder, Path.Combine(temporary.Path, "backup"), FileTreeBackup.Parse(FileTreeBackup.Serialize(folder, before))); + + Assert.Equal(before.Entries, FileTreeBackup.Capture(folder).Entries); + Assert.Contains("tables/empty/", before.Entries); + Assert.Equal("original", File.ReadAllText(Path.Combine(folder, "settings.txt"))); + Assert.False(File.Exists(Path.Combine(folder, "new.txt"))); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/SandboxLayout.cs b/tests/CheatEngine.Client.Tests/LiveQualification/SandboxLayout.cs new file mode 100644 index 0000000..31c0e5f --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/SandboxLayout.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Security.Cryptography; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The directory of one live run, below the run root and outside the repository: +/// <runRoot>/<yyyyMMddTHHmmssZ>-<4 hex>/ holds ce/ (the sandboxed Cheat Engine copy, with +/// the generated autorun driver), plugins/<bundle>/, sessions/<id>/ (transcript, debug output, +/// authorization manifest), receipts.jsonl, summary.json and the user state backups +/// (hkcu-backup.json, appdata-backup.json and appdata-backup/). The crash marker +/// registry-restore-pending.json sits in the run root, where the next run finds it. Nothing in a run directory is +/// ever committed as is: evidence is redacted first (the run path becomes <run>). +/// +[SupportedOSPlatform("windows")] +internal sealed class SandboxLayout +{ + /// The generated driver, loaded by the sandbox's autorun folder; the zz_ prefix makes it run last. + internal const string DriverScriptName = "zz_cheatengine_client_qualification.lua"; + + /// The crash marker, in the run root, that names the backups of a run whose user state is not yet restored. + internal const string RestoreMarkerName = "registry-restore-pending.json"; + + private SandboxLayout(string runRoot, string runId) + { + RunRoot = runRoot; + RunId = runId; + RunDirectory = Path.Combine(runRoot, runId); + } + + /// The run root that holds every run directory. + internal string RunRoot + { + get; + } + + /// The id of this run, yyyyMMddTHHmmssZ-xxxx. + internal string RunId + { + get; + } + + /// The directory of this run. + internal string RunDirectory + { + get; + } + + /// The sandboxed copy of Cheat Engine. + internal string CheatEngineDirectory => Path.Combine(RunDirectory, "ce"); + + /// The generated autorun driver inside the sandbox. + internal string DriverScriptPath => Path.Combine(CheatEngineDirectory, "autorun", DriverScriptName); + + /// The plugin bundles. + internal string PluginsDirectory => Path.Combine(RunDirectory, "plugins"); + + /// The receipt ledger. + internal string ReceiptsPath => Path.Combine(RunDirectory, "receipts.jsonl"); + + /// The run summary. + internal string SummaryPath => Path.Combine(RunDirectory, "summary.json"); + + /// The recursive snapshot of the Cheat Engine user registry key. + internal string RegistryBackupPath => Path.Combine(RunDirectory, "hkcu-backup.json"); + + /// The listing (path, length, SHA-256) of the Cheat Engine %APPDATA% folder. + internal string AppDataManifestPath => Path.Combine(RunDirectory, "appdata-backup.json"); + + /// The copy of the Cheat Engine %APPDATA% folder. + internal string AppDataBackupDirectory => Path.Combine(RunDirectory, "appdata-backup"); + + /// The crash marker of the run root. + internal string RestoreMarkerPath => Path.Combine(RunRoot, RestoreMarkerName); + + /// Creates a new, empty run directory below . + internal static SandboxLayout Create(string runRoot, DateTimeOffset now) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runRoot); + string root = Path.GetFullPath(runRoot); + for (int attempt = 0; attempt < 16; attempt++) + { + string id = string.Create(CultureInfo.InvariantCulture, + $"{now.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}-{RandomNumberGenerator.GetHexString(4, true)}"); + SandboxLayout layout = new(root, id); + if (Directory.Exists(layout.RunDirectory)) + { + continue; + } + + Directory.CreateDirectory(layout.RunDirectory); + Directory.CreateDirectory(layout.PluginsDirectory); + return layout; + } + + throw new IOException($"No free run directory name below '{root}' for {now:o}."); + } + + /// Creates (if needed) and returns the directory of one session. + internal string SessionDirectory(string session) + { + ArgumentException.ThrowIfNullOrWhiteSpace(session); + string directory = Path.Combine(RunDirectory, "sessions", session); + Directory.CreateDirectory(directory); + return directory; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioCatalog.cs b/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioCatalog.cs new file mode 100644 index 0000000..28fed30 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioCatalog.cs @@ -0,0 +1,105 @@ +namespace CheatEngine.Client.Tests.LiveQualification; + +/// One live qualification scenario of the Client. +/// The scenario id, for example Q05 or Q30.a. +/// +/// The qualification level its receipts state: C3 (exact host) or C4 (exact host plus a second +/// plugin). +/// +/// What the scenario establishes. +/// The sessions whose receipts carry it (S1 to S6; S5a and S5b are the two load orders of S5). +/// +/// Whether a dated waiver may stand in for it when it proves impossible (plan A12: only Q30.b, the reuse of a +/// process id, which cannot be produced on demand). +/// +internal sealed record QualificationScenario( + string Id, + string Level, + string Title, + IReadOnlyList Sessions, + bool Waivable = false); + +/// +/// The catalog of the Client's live qualification scenarios, the release gate, and the scenarios each Client +/// capability requires. It is plain data, compiled into CheatEngine.Client.Tests (the live runner and its evaluators) +/// and linked into CheatEngine.Client.Core.Tests, whose ClientCapabilityCatalogTests prove that every scenario +/// a capability of ClientCapabilityCatalog requires exists here and that +/// equals that catalog. +/// +internal static class ScenarioCatalog +{ + /// + /// The release-gate scenario proven in CI rather than on the host: the SDK consumer contract of + /// SdkConsumerContractTests, which both CI legs run. + /// + internal const string ContinuousIntegrationReleaseGate = "Q48"; + + /// Every live scenario, in id order. + internal static IReadOnlyList Scenarios + { + get; + } = + [ + new("Q05", "C3", "The plugin identity, activation and epochs", ["S1", "S2"]), + new("Q06", "C3", "A failed enable rolls back and the next enable reports it", ["S2"]), + new("Q09", "C4", "Two Client plugins enable and disable independently", ["S5a", "S5b"]), + new("Q10", "C4", "A CheatEngine.SDK 1.x neighbour and Client plugins load side by side", ["S5a", "S5b"]), + new("Q16", "C4", "A colliding export is refused, a third-party replacement survives disable, a kept function dies", + ["S2", "S5a", "S5b"]), + new("Q16.b", "C3", "A Client symbol lease registers and releases its symbol", ["S1"]), + new("Q19", "C3", "A worker's Client call is marshalled and its direct registration refused", ["S1"]), + new("Q20", "C3", "Byte and string round trips", ["S1"]), + new("Q21", "C3", "Integer and address boundaries, and the 2^53 marshalling rule", ["S1", "S4"]), + new("Q25", "C3", "A value scan finds its marker and float texts follow the rounded comparison", ["S1"]), + new("Q26", "C3", "A value scan session scans again, resets and releases, and a target change ends it", ["S1", "S3"]), + new("Q27", "C3", "A global AOB scan: matches, and an indeterminate zero", ["S1"]), + new("Q28", "C3", "A module AOB scan equals the global result inside the module", ["S1", "S3", "S4"]), + new("Q29", "C3", "AOB copy limits and cancellation", ["S1"]), + new("Q30.a", "C3", "An allocation's lifecycle, across a target change", ["S1", "S3"]), + new("Q30.b", "C3", "An allocation across a reused process id", ["S3"], Waivable: true), + new("Q31", "C3", "The configured pointer size", ["S1"]), + new("Q32", "C3", "Target facts and the instruction profiles of x64 and x86", ["S1", "S3", "S4"]), + new("Q33", "C3", "A partial memory batch", ["S1"]), + new("Q34", "C3", "Stale record ids and trusted table files", ["S1"]), + new("Q35", "C3", "An Auto Assembler patch applies and disables, and a failing one is refused", ["S1", "S3"]), + new("Q40", "C3", "Plugins built from the packed packages", ["S1", "S6"]), + new("Q43", "C3", "A disable runs every cleanup stage and aggregates the failures", ["S2"]), + new("Q44", "C3", "An opt-in capability is refused without its opt-in", ["S2"]), + new("Q45", "C3", "A probe changes no byte, process or module", ["S1"]), + new("Q46", "C3", "Logs and debug output carry no scenario data", ["S1", "S2"]) + ]; + + /// The release gate on the host (plan L24): these scenarios must pass, or carry a dated waiver. + internal static IReadOnlyList ReleaseGate + { + get; + } = ["Q09", "Q10", "Q40", "Q43", "Q44", "Q45", "Q46"]; + + /// + /// The scenarios each Client capability requires, by capability id; it equals the RequiredScenarios of + /// ClientCapabilityCatalog (plan L7). Client.UnsafeLuaExecution is never qualified. + /// + internal static IReadOnlyDictionary> CapabilityScenarios + { + get; + } = new Dictionary>(StringComparer.Ordinal) + { + ["Client.ProcessSelection"] = ["Q30.a", "Q31", "Q32"], + ["Client.TypedMemory"] = ["Q20", "Q21", "Q33"], + ["Client.PatternScanning"] = ["Q27", "Q28", "Q29"], + ["Client.ValueScanning"] = ["Q25", "Q26"], + ["Client.Inspection"] = ["Q16.b", "Q28"], + ["Client.Tables"] = ["Q34"], + ["Client.ProtectedLua"] = ["Q05", "Q16", "Q19"], + ["Client.UnsafeLuaExecution"] = [], + ["Client.Allocations"] = ["Q30.a"], + ["Client.Assembly"] = ["Q32"], + ["Client.AutoAssemblerPatches"] = ["Q35", "Q44"] + }; + + /// Whether names a scenario of the catalog. + internal static bool Contains(string id) + { + return Scenarios.Any(scenario => string.Equals(scenario.Id, id, StringComparison.Ordinal)); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioCatalogTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioCatalogTests.cs new file mode 100644 index 0000000..275dfd1 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioCatalogTests.cs @@ -0,0 +1,134 @@ +using System.Reflection; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Tests.SdkContract; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The live scenario catalog is complete and coherent: every scenario has checks in the sessions it names, every +/// check belongs to a catalogued scenario and session, the release gate is covered, and the capability map names +/// every Client capability with catalogued scenarios only (its equality with ClientCapabilityCatalog is proven +/// by ClientCapabilityCatalogTests of Core, which links this catalog). +/// +[SupportedOSPlatform("windows")] +public sealed partial class ScenarioCatalogTests +{ + [Fact] + public void ScenarioIdsAreWellFormedDistinctAndOrdered() + { + string[] ids = [.. ScenarioCatalog.Scenarios.Select(static scenario => scenario.Id)]; + + Assert.All(ids, static id => Assert.Matches(ScenarioId(), id)); + Assert.Equal(ids.Length, ids.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(ids.Order(StringComparer.Ordinal), ids); + Assert.All(ScenarioCatalog.Scenarios, static scenario => Assert.Contains(scenario.Level, ReceiptLedger.Levels)); + } + + [Fact] + public void EveryScenarioHasChecksInEachSessionItNamesAndOnlyThere() + { + HashSet sessions = new(SessionPlans.All.Select(static plan => plan.Session), StringComparer.Ordinal); + foreach (QualificationScenario scenario in ScenarioCatalog.Scenarios) + { + Assert.All(scenario.Sessions, session => Assert.Contains(session, sessions)); + string[] checkedSessions = + [ + .. ScenarioEvaluators.Checks.Where(check => check.Scenario == scenario.Id).Select(static check => check.Session) + .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal) + ]; + Assert.Equal(scenario.Sessions.Order(StringComparer.Ordinal), checkedSessions); + } + } + + [Fact] + public void EveryCheckNamesACatalogScenarioAndIsUniqueInItsSession() + { + Assert.All(ScenarioEvaluators.Checks, static check => Assert.True(ScenarioCatalog.Contains(check.Scenario), check.Scenario)); + (string, string, string)[] keys = + [.. ScenarioEvaluators.Checks.Select(static check => (check.Scenario, check.Session, check.Name))]; + Assert.Equal(keys.Length, keys.Distinct().Count()); + Assert.All(ScenarioEvaluators.Checks, static check => Assert.False(string.IsNullOrWhiteSpace(check.Expectation))); + } + + [Fact] + public void TheReleaseGateIsCataloguedAndCoveredByLiveChecks() + { + Assert.Equal(["Q09", "Q10", "Q40", "Q43", "Q44", "Q45", "Q46"], ScenarioCatalog.ReleaseGate); + Assert.All(ScenarioCatalog.ReleaseGate, static id => + Assert.Contains(ScenarioEvaluators.Checks, check => check.Scenario == id)); + Assert.Equal("Q48", ScenarioCatalog.ContinuousIntegrationReleaseGate); + Assert.False(ScenarioCatalog.Contains(ScenarioCatalog.ContinuousIntegrationReleaseGate)); + } + + [Fact] + public void TheContinuousIntegrationReleaseGateIsProvenByTheSdkConsumerContractTests() + { + IEnumerable traits = typeof(SdkConsumerContractTests) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .SelectMany(static method => method.GetCustomAttributesData()) + .Where(static attribute => attribute.AttributeType == typeof(TraitAttribute)); + + Assert.Contains(traits, static trait => (string?) trait.ConstructorArguments[0].Value == "Qualification" && + (string?) trait.ConstructorArguments[1].Value == ScenarioCatalog.ContinuousIntegrationReleaseGate); + } + + [Fact] + public void TheCapabilityMapNamesEveryClientCapabilityWithCatalogScenarios() + { + string[] capabilities = + [ + .. typeof(ClientCapabilityId).GetProperties(BindingFlags.Public | BindingFlags.Static) + .Where(static property => property.PropertyType == typeof(ClientCapabilityId)) + .Select(static property => ((ClientCapabilityId) property.GetValue(null)!).Value) + ]; + + Assert.Equal(capabilities.Order(StringComparer.Ordinal), ScenarioCatalog.CapabilityScenarios.Keys.Order(StringComparer.Ordinal)); + Assert.All(ScenarioCatalog.CapabilityScenarios.Values.SelectMany(static scenarios => scenarios), + static scenario => Assert.True(ScenarioCatalog.Contains(scenario), scenario)); + Assert.Empty(ScenarioCatalog.CapabilityScenarios[ClientCapabilityId.UnsafeLuaExecution.Value]); + } + + [Fact] + public void OnlyTheReuseOfAProcessIdIsWaivableAndOnlyCoexistenceIsC4() + { + Assert.Equal(["Q30.b"], ScenarioCatalog.Scenarios.Where(static scenario => scenario.Waivable).Select(static scenario => scenario.Id)); + Assert.All(ScenarioCatalog.Scenarios.Where(static scenario => scenario.Level == "C4"), + static scenario => Assert.Contains(scenario.Sessions, static session => session.StartsWith("S5", StringComparison.Ordinal))); + } + + [Fact] + public void EachLiveFactCarriesTheScenarioTraitsOfItsSessions() + { + Dictionary facts = new(StringComparer.Ordinal) + { + ["S1"] = typeof(LiveSessionS1Tests), + ["S2"] = typeof(LiveSessionS2Tests), + ["S3"] = typeof(LiveSessionS3Tests), + ["S4"] = typeof(LiveSessionS4Tests), + ["S5"] = typeof(LiveSessionS5Tests), + ["S6"] = typeof(LiveSessionS6Tests) + }; + + foreach ((string trait, Type fact) in facts) + { + string[] expected = + [ + .. ScenarioEvaluators.Checks.Where(check => check.Session[..2] == trait).Select(static check => check.Scenario) + .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal) + ]; + string[] traits = + [ + .. fact.GetCustomAttributesData().Where(static attribute => attribute.AttributeType == typeof(TraitAttribute) && + (string?) attribute.ConstructorArguments[0].Value == "Qualification") + .Select(static attribute => (string) attribute.ConstructorArguments[1].Value!).Order(StringComparer.Ordinal) + ]; + Assert.Equal(expected, traits); + } + } + + [GeneratedRegex(@"^Q\d{2}(\.[a-z])?$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ScenarioId(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioEvaluators.cs b/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioEvaluators.cs new file mode 100644 index 0000000..9da3459 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/ScenarioEvaluators.cs @@ -0,0 +1,802 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Text.Json; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// One check of a scenario, evaluated on one session's evidence. +/// The scenario id of . +/// The session whose evidence it reads. +/// The check name, unique within its scenario and session. +/// What the check requires, as the receipt states it. +/// The evaluator. +[SupportedOSPlatform("windows")] +internal sealed record QualificationCheck( + string Scenario, + string Session, + string Name, + string Expectation, + Func Evaluate) +{ + /// The receipt of this check in . + internal QualificationReceipt ToReceipt(string runId, SessionEvidence evidence) + { + CheckResult result; + try + { + result = Evaluate(evidence); + } + catch (Exception exception) when (exception is InvalidOperationException or FormatException or ArgumentException) + { + result = CheckResult.Failed($"the evaluator failed on the evidence ({exception.GetType().Name})"); + } + + string level = ScenarioCatalog.Scenarios.Single(scenario => scenario.Id == Scenario).Level; + return new QualificationReceipt(runId, Session, Scenario, Name, level, result.Status, Expectation, result.Observation); + } +} + +/// +/// The C# evaluators of every live check. Each reads the driver transcript (the harness's JSON observations and the +/// driver's own steps), the debug output, the lifecycle sink and the runner's facts, and returns Passed, Failed or +/// NotExecuted with the facts it rests on. A missing step is NotExecuted, never a pass; a check that needs an operator +/// toggle stays NotExecuted unless the driver observed the toggle done; nothing is inferred from a count that varies +/// with the loaded modules (Q28 compares the bounded result with the global result inside the module). +/// +[SupportedOSPlatform("windows")] +internal static class ScenarioEvaluators +{ + /// Every check, by scenario then session. + /// The start of the identification line in the debug output (SDK DebugOutputLogSink and LoadIdentification). + internal const string IdentificationPrefix = "[CheatEngine.SDK.Hosting] Information: CheatEngineSdkIdentification: "; + + private const string NoDisable = "the lifecycle sink records no disabled enable: no operator toggle ran, and what " + + "closeCE disables is a fact the S0 spike records"; + + internal static IReadOnlyList Checks + { + get; + } = Build(); + + private static List Build() + { + List checks = []; + + void Add(string scenario, string session, string name, string expectation, Func evaluate) + { + checks.Add(new QualificationCheck(scenario, session, name, expectation, evaluate)); + } + + // Q05: the plugin identity and its activation epochs. + Add("Q05", "S1", "harness-identity", "status() reports an active harness with a plugin id and an activation epoch", + static evidence => evidence.Observe("status", + static observed => observed.Is("plugin.active") && observed.Number("plugin.pluginId") > 0 && + observed.Number("plugin.epoch") > 0, + "plugin.active", "plugin.pluginId", "plugin.epoch")); + Add("Q05", "S1", "identification-line", + "the SDK identification line of the enable names the harness assembly as its plugin.assembly", + IdentificationLine); + Add("Q05", "S2", "reenable-epochs", "a re-enable reports a new activation with a later epoch", + static evidence => evidence.AfterOperator(["toggle-disable", "toggle-enable"], + () => evidence.Observe("status-after-reenable", + static observed => observed.Number("plugin.activations") >= 2 && + observed.Number("plugin.lastEpoch") > observed.Number("plugin.previousEpoch"), + "plugin.activations", "plugin.lastEpoch", "plugin.previousEpoch"))); + + // Q06: a failed enable rolls back and the next enable reports it. + Add("Q06", "S2", "failed-enable-reported", "an enable whose Configure throws fails, and the next enable reports it", + static evidence => evidence.AfterOperator(["toggle-disable-for-fault", "toggle-enable-faulted", "toggle-enable-after-fault"], + () => evidence.Observe("status-after-fault", + static observed => observed.Is("plugin.active") && + observed.Element("ledger")?.ToString().Contains("configure.threw", StringComparison.Ordinal) == true, + "plugin.active", "plugin.enableAttempts", "plugin.activations"))); + + foreach (string session in (string[]) ["S5a", "S5b"]) + { + // Q09: two Client plugins enable and disable independently. + Add("Q09", session, "both-enabled", "Plugin A and Plugin B both answer after loading", + static evidence => Both(evidence.Value("a-identity", static value => value.StartsWith("Plugin=A", StringComparison.Ordinal)), + evidence.Value("b-identity", static value => value.StartsWith("Plugin=B", StringComparison.Ordinal)))); + Add("Q09", session, "independent-disable", "disabling Plugin A leaves Plugin B answering", + static evidence => evidence.AfterOperator(["toggle-disable-a"], + () => evidence.Value("b-ping-after-a-disabled", static value => long.TryParse(value, CultureInfo.InvariantCulture, out _)))); + Add("Q09", session, "b-disables-alone", "Plugin B then disables on its own: its functions are gone", + static evidence => evidence.AfterOperator(["toggle-disable-b"], + () => evidence.Value("toggle-disable-b", static value => value == "disabled"))); + + // Q10: a CheatEngine.SDK 1.x neighbour and the Client plugins side by side. + Add("Q10", session, "neighbour-identity", + "the SDK 1.x neighbour answers with its own SDK Hosting major 1, its packaged bridge and another load context", + static evidence => evidence.Value("neighbour-identity", static value => + { + IReadOnlyDictionary facts = NeighbourPluginSource.ParseIdentity(value); + return facts.GetValueOrDefault("SdkHostingMajorIs1") && facts.GetValueOrDefault("BridgeMatchesPackage") && + facts.GetValueOrDefault("OwnLoadContextIsNotDefault") && + facts.GetValueOrDefault("SdkHostingMajor2LoadedElsewhere"); + })); + Add("Q10", session, "client-sdk-hosting-2", "Plugin A and Plugin B run on CheatEngine.SDK.Hosting 2.0.0.0", + static evidence => Both( + evidence.Value("a-identity", static value => value.Contains("CheatEngine.SDK.Hosting, Version=2.0.0.0", StringComparison.Ordinal)), + evidence.Value("b-identity", static value => value.Contains("CheatEngine.SDK.Hosting, Version=2.0.0.0", StringComparison.Ordinal)))); + Add("Q10", session, "both-answer", "both Client plugins answer with the neighbour loaded", + static evidence => Both(evidence.Value("a-ping", static value => long.TryParse(value, CultureInfo.InvariantCulture, out _)), + evidence.Value("b-ping", static value => long.TryParse(value, CultureInfo.InvariantCulture, out _)))); + + // Q16: collision refusal and a third-party replacement. + Add("Q16", session, "collision-refused", "the colliding plugin leaves Plugin A's collision marker intact", + static evidence => Both(evidence.Value("a-collision-before", static value => value == "CollisionOwner=A"), + evidence.Value("a-collision-after", static value => value == "CollisionOwner=A"))); + Add("Q16", session, "third-party-survives-disable", + "a third-party replacement of a Plugin A global survives the disable of Plugin A", + static evidence => evidence.AfterOperator(["toggle-disable-a"], + () => evidence.Value("third-party-survives", static value => value == "true"))); + } + + Add("Q16", "S2", "kept-function-dies", "a harness function kept by Lua raises a classified error after the disable", + static evidence => evidence.AfterOperator(["toggle-disable"], () => evidence.ExpectError("kept-function-after-disable"))); + + // Q16.b: a Client symbol lease. + Add("Q16.b", "S1", "registered", "the lease registers the symbol on the scratch address", + static evidence => Both(evidence.Observe("symbol-register", static observed => observed.Is("ok"), "ok", "name"), + evidence.Observe("symbol-state-registered", static observed => observed.Is("resolvesToLeasedAddress"), + "resolves", "resolvesToLeasedAddress"))); + Add("Q16.b", "S1", "released", "disposing the lease unregisters the symbol", + static evidence => Both(evidence.Observe("symbol-release", static observed => observed.Is("released"), "released"), + evidence.Observe("symbol-state-released", static observed => observed.Bool("resolves") == false, + "resolves", "lease.released"))); + + // Q19: worker admission. + Add("Q19", "S1", "marshalled-call", "a Client call from a worker thread is marshalled to the main thread", + static evidence => evidence.Observe("worker-result", + static observed => observed.Is("offMainThread") && observed.Is("marshalledCall.succeeded"), + "offMainThread", "marshalledCall.succeeded")); + Add("Q19", "S1", "direct-register-refused", + "a direct Register from a worker is refused with InvalidState and NotStarted, and publishes nothing", + static evidence => Both(evidence.Observe("worker-result", + static observed => observed.Bool("directRegister.registered") == false && + observed.Text("directRegister.failure.kind") == "InvalidState" && + observed.Text("directRegister.failure.hostEffect") == "NotStarted", + "directRegister.registered", "directRegister.failure.kind", "directRegister.failure.hostEffect"), + evidence.Value("worker-probe-global", static value => value == "nil"))); + + // Q20: byte and string round trips. + foreach (string kind in (string[]) ["bytes-with-nul", "utf8-multibyte", "utf16-with-nul"]) + { + Add("Q20", "S1", kind, $"the {kind} value is written, read back exactly and the original restored", + evidence => evidence.Observe("roundtrip-" + kind, + static observed => observed.Is("ok") && observed.Is("bytesEqual") && observed.Is("originalRestored") && + observed.Bool("textEqual") != false, + "ok", "bytesEqual", "textEqual", "originalRestored")); + } + + // Q21: integer and address boundaries. + Add("Q21", "S1", "int32-minus-one", "-1 reads back as int -1 and uint 0xFFFFFFFF", + static evidence => evidence.Observe("roundtrip-int32-minus-one", + static observed => observed.Is("signedEqual") && observed.Is("unsignedEqual") && observed.Is("originalRestored"), + "signedEqual", "unsignedEqual", "originalRestored")); + Add("Q21", "S1", "uint32-max", "uint.MaxValue reads back as uint and as int -1", + static evidence => evidence.Observe("roundtrip-uint32-max", + static observed => observed.Is("unsignedEqual") && observed.Is("signedEqual") && observed.Is("originalRestored"), + "unsignedEqual", "signedEqual", "originalRestored")); + foreach (string session in (string[]) ["S1", "S4"]) + { + Add("Q21", session, "int64-limits", "the 64-bit limits and 2^53 + 1 read back exactly", + static evidence => evidence.Observe("roundtrip-int64-limits", + static observed => observed.Is("allEqual") && observed.Is("originalRestored"), "allEqual", "originalRestored")); + } + + Add("Q21", "S1", "address-above-4gib", "an address above 4 GiB reads back exactly on the x64 target", + static evidence => evidence.Observe("roundtrip-address-above-4gib", + static observed => observed.Is("valueEqual") && observed.Is("rawEqual") && observed.Is("originalRestored"), + "valueEqual", "rawEqual", "originalRestored")); + Add("Q21", "S4", "address-above-4gib-refused", "an address above 4 GiB is refused on the x86 target", + static evidence => evidence.Observe("roundtrip-address-above-4gib", + static observed => observed.Text("writeFailure.kind") == "OperationRejected" && observed.Is("originalRestored"), + "writeFailure.kind", "writeFailure.hostEffect", "originalRestored")); + Add("Q21", "S1", "integer-exact", "an integer above 2^53 is marshalled exactly", + static evidence => evidence.Observe("integer-exact", static observed => observed.Text("value") == "9007199254740993", + "value")); + Add("Q21", "S1", "integer-float-below-2p53", "a float below 2^53 is marshalled exactly", + static evidence => evidence.Observe("integer-float-below", + static observed => observed.Text("value") == "9007199254740991", "value")); + Add("Q21", "S1", "integer-float-2p53-refused", "a float of 2^53 is refused with a Lua error, never rounded", + static evidence => evidence.ExpectError("integer-float-2p53")); + Add("Q21", "S1", "address-exact", "an integer address is marshalled exactly", + static evidence => evidence.Observe("address-exact", + static observed => observed.Text("value") == "140737488355327", "value")); + Add("Q21", "S1", "address-float-2p53-refused", "a float address of 2^53 is refused with a Lua error", + static evidence => evidence.ExpectError("address-float-2p53")); + + // Q25 and Q26: value scans. + Add("Q25", "S1", "first-scan-finds-marker", "a first scan of the scratch region finds the int32 marker", + static evidence => evidence.Observe("value-scan-first", + static observed => observed.Is("scanned") && observed.Is("results.containsMarker"), + "scanned", "results.resultCount", "results.containsMarker", "session.state")); + Add("Q25", "S1", "decimal-tolerance", + "3.14159 is found by its 5, 2 and 0 decimal texts and not by 3.2 or 3.15, as float and as double; its " + + "3-decimal text 3.142 gives one verdict for both, which names the rounding rule Cheat Engine applies", + DecimalTolerance); + Add("Q26", "S1", "next-scan-finds-marker", "a next scan finds the new marker", + static evidence => evidence.Observe("value-scan-next", + static observed => observed.Is("scanned") && observed.Is("results.containsMarker"), + "scanned", "results.resultCount", "results.containsMarker")); + Add("Q26", "S1", "reset", "the session resets", + static evidence => evidence.Observe("value-scan-reset", static observed => observed.Is("reset"), "reset", "session.state")); + Add("Q26", "S1", "released", "the session releases as Released", + static evidence => evidence.Observe("value-scan-release", + static observed => observed.Text("release.kind") == "Released" && observed.Is("release.complete"), + "release.kind", "release.hostEffect", "session.state")); + Add("Q26", "S3", "refused-after-target-change", + "after Cheat Engine selects another process, the session has ended with RefusedTargetChanged and manual recovery", + static evidence => evidence.Observe("value-scan-state-on-b", + static observed => observed.Is("session.released") && observed.Text("session.state") == "Closed" && + observed.Text("lastRelease.kind") == "RefusedTargetChanged" && + observed.Is("lastRelease.requiresManualRecovery"), + "session.released", "session.state", "lastRelease.kind", "lastRelease.requiresManualRecovery")); + Add("Q26", "S3", "nothing-released-on-b", + "a release attempt on the other process makes no Cheat Engine call: it returns the ending refusal again", + static evidence => evidence.Observe("value-scan-release-on-b", + static observed => observed.Text("release.kind") == "RefusedTargetChanged" && + observed.Text("release.hostEffect") == "NotStarted" && + observed.Text("lastRelease.kind") == "RefusedTargetChanged" && + observed.Is("lastRelease.requiresManualRecovery"), + "release.kind", "release.hostEffect", "lastRelease.kind")); + Add("Q26", "S3", "file-as-process-refused", "no session is created on a file opened as a process", + static evidence => evidence.Observe("value-scan-unidentified", + static observed => observed.Bool("created") == false && observed.Text("failure.kind") == "TargetIdentityUnavailable", + "created", "failure.kind", "failure.hostEffect")); + + // Q27: global AOB scans. + Add("Q27", "S1", "known-pattern-matches", "the module header pattern gives Matches on the global route", + static evidence => evidence.Observe("aob-known", + static observed => observed.Is("ok") && observed.Text("route.scope") == "GlobalHostScan" && + observed.Text("route.hostOutcome") == "Matches" && observed.Number("matches.count") >= 1, + "route.scope", "route.hostOutcome", "matches.count")); + Add("Q27", "S1", "absent-pattern-indeterminate", "an absent random pattern is IndeterminateHostResult with NoResult", + static evidence => evidence.Observe("aob-absent", static observed => observed.Is("checks.globalZeroIsIndeterminate"), + "failure.kind", "route.hostOutcome")); + + // Q28: module AOB scans. + foreach (string session in (string[]) ["S1", "S4"]) + { + Add("Q28", session, "module-scan-exact", + "the bounded module scan finds the module base and equals the global result inside the module", + static evidence => evidence.Observe("aob-module", + static observed => observed.Text("route.scope") == "HostBoundedRange" && observed.Is("route.targetIdentityVerified") && + observed.Is("moduleCheck.containsBase") && observed.Is("moduleCheck.filterExact"), + "route.scope", "route.reason", "moduleCheck.containsBase", "moduleCheck.filterExact", + "moduleCheck.globalComplete")); + Add("Q28", session, "module-absent-no-matches", "an absent pattern in the module is a factual NoMatches", + static evidence => evidence.Observe("aob-module-absent", static observed => observed.Is("checks.boundedZeroIsNoMatches"), + "route.scope", "route.hostOutcome")); + } + + Add("Q28", "S3", "file-as-process-fallback", + "on a file opened as a process a module scan takes the global route with the managed filter, unverified", + static evidence => evidence.Observe("aob-module-unidentified", + static observed => observed.Text("route.scope") == "GlobalHostScanWithManagedFilter" && + observed.Text("route.reason") == "TargetIdentityNotQualified" && + observed.Bool("route.targetIdentityVerified") == false, + "route.scope", "route.reason", "route.targetIdentityVerified", "failure.kind")); + + // Q29: copy limits and cancellation. + Add("Q29", "S1", "limit-truncates", "a limit of 1 truncates explicitly", + static evidence => evidence.Observe("aob-limit", + static observed => observed.Is("truncated") && observed.Is("checks.truncationExplicit"), + "truncated", "matches.count", "checks.truncationExplicit")); + Add("Q29", "S1", "cancellation-honest", "a cancellation either publishes nothing or completes untruncated", + static evidence => evidence.Observe("aob-cancel", static observed => observed.Is("checks.cancellationHonest"), + "failure.kind", "truncated", "checks.cancellationHonest")); + + // Q30.a: allocations. + Add("Q30.a", "S1", "allocated", + "an allocation is a committed region that starts at the lease address, held by an active lease", + static evidence => Both(evidence.Observe("allocation-allocate", + static observed => observed.Is("allocated") && observed.Text("region.state") == "Committed" && + observed.Is("region.startsAtAllocation"), + "allocated", "region.state", "region.protection"), + evidence.Observe("allocation-state", + static observed => observed.Bool("lease.released") == false && + observed.Bool("lease.requiresManualRecovery") == false && + observed.Element("lastRelease") is { ValueKind: JsonValueKind.Null }, + "lease.released", "lease.requiresManualRecovery"))); + Add("Q30.a", "S1", "released", "its release is Released and frees the region", + static evidence => evidence.Observe("allocation-release", + static observed => observed.Text("release.kind") == "Released" && observed.Is("release.complete") && + observed.Text("regionAfterRelease.state") == "Free", + "release.kind", "regionAfterRelease.state")); + Add("Q30.a", "S3", "refused-after-target-change", + "after Cheat Engine selects another process, the lease has ended with RefusedTargetChanged and manual recovery", + static evidence => evidence.Observe("allocation-state-on-b", + static observed => observed.Is("lease.released") && observed.Text("lastRelease.kind") == "RefusedTargetChanged" && + observed.Is("lease.requiresManualRecovery"), + "lease.released", "lastRelease.kind", "lease.requiresManualRecovery")); + Add("Q30.a", "S3", "nothing-freed-on-b", + "a release attempt on the other process frees nothing: it returns the ending refusal again", + static evidence => evidence.Observe("allocation-release-on-b", + static observed => observed.Is("releasedBefore") && + observed.Text("release.kind") == "RefusedTargetChanged" && + observed.Text("release.hostEffect") == "NotStarted" && + observed.Is("requiresManualRecovery"), + "releasedBefore", "release.kind", "release.hostEffect", "requiresManualRecovery", + "onAuthorizedTarget")); + Add("Q30.a", "S3", "consumed-owner-stays-ended", "back on the first process the refused lease stays ended", + static evidence => evidence.Observe("allocation-state-back-on-a", + static observed => observed.Is("lease.released") && observed.Text("lastRelease.kind") == "RefusedTargetChanged", + "lease.released", "lastRelease.kind")); + Add("Q30.a", "S3", "new-allocation-released", "a new allocation on the first process releases as Released", + static evidence => evidence.Observe("allocation-release-new", + static observed => observed.Text("release.kind") == "Released" && observed.Is("release.complete"), + "release.kind", "regionAfterRelease.state")); + Add("Q30.a", "S3", "file-as-process-refused", "no allocation is made on a file opened as a process", + static evidence => evidence.Observe("allocation-unidentified", + static observed => observed.Bool("allocated") == false && observed.Text("failure.kind") == "TargetIdentityUnavailable", + "allocated", "failure.kind", "failure.hostEffect")); + Add("Q30.b", "S3", "process-id-reuse", "an allocation is refused on a process that reuses the process id", + static _ => CheckResult.NotExecuted("the reuse of a process id cannot be produced on demand; this scenario is " + + "waivable (plan A12)")); + + // Q31: the configured pointer size. + Add("Q31", "S1", "configured-4", "setPointerSize(4) is reported as a configured size of 4 that differs from the bitness", + static evidence => evidence.Observe("runtime-pointer-4", + static observed => observed.Is("configuredPointerSize.exposedByClient") && + observed.Number("configuredPointerSize.bytes") == 4 && + observed.Is("configuredPointerSize.differsFromBitness"), + "configuredPointerSize.bytes", "configuredPointerSize.differsFromBitness", "process.bitnessBytes")); + Add("Q31", "S1", "width-refusal", "an address write is refused while the configured size differs from the bitness", + static evidence => evidence.Observe("pointer-width-refusal", + static observed => observed.Element("writeFailure") is not null && observed.Is("originalRestored"), + "writeFailure.kind", "writeFailure.hostEffect", "originalRestored")); + Add("Q31", "S1", "restored-8", "setPointerSize(8) restores a configured size equal to the bitness", + static evidence => evidence.Observe("runtime-pointer-8", + static observed => observed.Number("configuredPointerSize.bytes") == 8 && + observed.Bool("configuredPointerSize.differsFromBitness") == false, + "configuredPointerSize.bytes", "configuredPointerSize.differsFromBitness")); + + // Q32: target facts and instruction profiles. + Add("Q32", "S1", "x64-facts", "Cheat Engine 7.7.0.10621 x64 on Windows, a local x64 target of bitness 8", + static evidence => evidence.Observe("runtime", + static observed => observed.Text("host.cheatEngineVersion") == "7.7.0.10621" && + observed.Number("host.cheatEngineBitnessBytes") == 8 && + observed.Text("host.operatingSystem") == "Windows" && + observed.Text("process.backend") == "LocalProcess" && + observed.Text("process.targetArchitecture") == "X64" && observed.Number("process.bitnessBytes") == 8, + "host.cheatEngineVersion", "host.cheatEngineBitnessBytes", "host.operatingSystem", "process.backend", + "process.targetArchitecture", "process.bitnessBytes")); + Add("Q32", "S4", "x86-facts", "a local x86 target of bitness 4", + static evidence => evidence.Observe("runtime", + static observed => observed.Text("process.backend") == "LocalProcess" && + observed.Text("process.targetArchitecture") == "X86" && observed.Number("process.bitnessBytes") == 4, + "process.backend", "process.targetArchitecture", "process.bitnessBytes")); + foreach (string session in (string[]) ["S1", "S4"]) + { + Add("Q32", session, "instruction-profile", + "nop, ret, int3 and push of the frame pointer assemble to 90, C3, CC and 55; short and long jumps to EB and E9", + static evidence => evidence.Observe("instructions", static observed => + Assembled(observed, "nop", "90") && Assembled(observed, "ret", "C3") && Assembled(observed, "int3", "CC") && + Assembled(observed, "push-frame-pointer", "55") && Assembled(observed, "jmp-short", "EB") && + Assembled(observed, "jmp-long", "E9"), "bitnessBytes")); + Add("Q32", session, "instruction-round-trip", + "mov eax,1; ret written to the scratch window disassembles, measures and finds its previous instruction", + static evidence => evidence.Observe("instructions", + static observed => observed.Is("roundTrip.disassembled") && observed.Is("roundTrip.bytesEqual") && + observed.Number("roundTrip.length") == observed.Number("roundTrip.snapshotLength") && + observed.Is("roundTrip.previousIsWindow") && observed.Is("originalRestored"), + "roundTrip.opcode", "roundTrip.length", "roundTrip.previousIsWindow", "originalRestored")); + } + + Add("Q32", "S3", "file-as-process-backend", "a file opened as a process is reported with the FileAsProcess backend", + static evidence => evidence.Observe("runtime-file-as-process", + static observed => observed.Text("process.backend") == "FileAsProcess", "process.backend", "runtime.backend")); + Add("Q32", "S3", "target-change-observed", + "each process Cheat Engine selects (A, B, then A again) is the one the Client reports next, with a later selection epoch", + TargetSwitches); + + // Q33: a partial batch. + Add("Q33", "S1", "partial-effect", "a batch whose third address is unmapped reports two writes and a partial effect", + static evidence => evidence.Observe("batch-partial", + static observed => observed.Is("checks.partialEffectExposed") && observed.Is("checks.readBackConfirms") && + observed.Is("checks.originalRestored"), + "outcome.completed", "outcome.failedIndex", "outcome.effectState")); + + // Q34: tables. + Add("Q34", "S1", "destroyed-record-refused", "the id of a destroyed record is refused, never answered by another", + static evidence => evidence.Observe("table-probe-destroyed", + static observed => observed.Is("checks.oldReferenceRefused") && !observed.Is("checks.reusedAsAnotherRecord"), + "found", "failure.kind")); + Add("Q34", "S1", "table-file-round-trip", "a table saved below the table root loads again", + static evidence => Both(evidence.Observe("table-save", static observed => observed.Is("saved") && observed.Is("fileExists"), + "saved", "fileExists"), + evidence.Observe("table-load", static observed => observed.Is("loaded"), "loaded", "failure.kind"))); + Add("Q34", "S1", "loaded-table-ends-ids", "a table load ends every record id handed out before", + static evidence => evidence.Observe("table-probe-loaded", static observed => observed.Is("checks.oldReferenceRefused"), + "found", "failure.kind")); + Add("Q34", "S1", "outside-root-refused", "a save outside the table root is refused before any Cheat Engine call", + static evidence => evidence.Observe("table-save-outside", + static observed => observed.Bool("saved") == false && observed.Text("failure.hostEffect") == "NotStarted" && + observed.Bool("fileExists") == false, + "saved", "failure.kind", "failure.hostEffect")); + + // Q35: Auto Assembler patches. + Add("Q35", "S1", "benign-applied", "the benign patch checks, applies on the target Cheat Engine selected and registers its symbol", + static evidence => Both(evidence.Observe("aa-check", static observed => observed.Is("accepted"), "accepted"), + evidence.Observe("aa-apply", + static observed => observed.Is("applied") && observed.Is("lease.canDisable") && + observed.Is("symbolResolves") && + observed.Number("lease.selectionEpoch") == observed.Number("selectionEpochBeforeApply"), + "applied", "lease.canDisable", "symbolResolves", "lease.selectionEpoch", + "selectionEpochBeforeApply"))); + Add("Q35", "S1", "benign-disabled", "releasing the patch runs its [DISABLE] section: Released and the symbol is gone", + static evidence => evidence.Observe("aa-release", + static observed => observed.Text("release.kind") == "Released" && observed.Bool("symbolResolves") == false, + "release.kind", "symbolResolves")); + Add("Q35", "S1", "failing-refused", "a patch that does not assemble is refused and leaves no lease", + static evidence => evidence.Observe("aa-apply-failing", + static observed => observed.Bool("applied") == false && observed.Element("failure") is not null, + "applied", "failure.kind", "failure.hostEffect")); + Add("Q35", "S3", "refused-after-target-change", + "after a target change the patch lease ended with RefusedTargetChanged and manual recovery", + static evidence => evidence.Observe("aa-state-on-b", + static observed => observed.Is("lease.released") && observed.Is("lease.requiresManualRecovery") && + observed.Text("lastRelease.kind") == "RefusedTargetChanged", + "lease.released", "lastRelease.kind", "lease.requiresManualRecovery")); + Add("Q35", "S3", "nothing-disabled-on-b", + "a release attempt on the other process makes no Cheat Engine call: it returns the ending refusal again", + static evidence => evidence.Observe("aa-release-on-b", + static observed => observed.Text("release.kind") == "RefusedTargetChanged" && + observed.Text("release.hostEffect") == "NotStarted" && + observed.Text("lastRelease.kind") == "RefusedTargetChanged", + "release.kind", "release.hostEffect", "lastRelease.kind", "symbolResolves")); + + // Q40: plugins built from the packed packages. + Add("Q40", "S1", "client-assemblies-packed", "every Client assembly of the harness bundle equals the packed package's", + static evidence => evidence.FromFact(SessionFacts.BundleClientAssembliesMatch, static value => value == "true")); + Add("Q40", "S1", "bridge-reviewed", "the deployed bridge is the reviewed CheatEngine.SDK bridge", + static evidence => evidence.Fact(SessionFacts.ExpectedBridgeSha256) is { } expected + ? evidence.FromFact(SessionFacts.BundleBridgeSha256, value => value == expected) + : CheckResult.NotExecuted("the reviewed bridge hash was not established")); + Add("Q40", "S1", "loaded-client-version", + "the loaded Client Hosting and Core assemblies are both reported and carry the packed package version", + LoadedClientVersion); + Add("Q40", "S6", "template-sdk-content-hash", + "the instantiated template restores the reviewed CheatEngine.SDK package (its content hash)", + static evidence => evidence.Fact(SessionFacts.ExpectedSdkContentHash) is { } expected + ? evidence.FromFact(SessionFacts.TemplateSdkContentHash, value => value == expected) + : CheckResult.NotExecuted("the reviewed content hash was not established")); + Add("Q40", "S6", "template-answers", "the template's status global answers after loadPlugin", + static evidence => evidence.Value("template-status", static value => value.Length > 0)); + Add("Q40", "S6", "template-deps-isolated", "the template bundle's deps.json names no path of the build workspace", + static evidence => evidence.FromFact(SessionFacts.TemplateDepsWorkspacePaths, static value => value == "0")); + + // Q43: the last disable (at closeCE, or the operator's last toggle) runs every cleanup stage and aggregates the + // failures; every enable of S2 but the one that must fail carries the ModuleOnDisabling fault. + Add("Q43", "S2", "cleanup-continues-past-fault", + "in the last enable that was disabled, the faulty module's disable is followed by the first module's disable " + + "and the resource cleanup", + static evidence => LifecycleOrder(evidence, "fault.disabling.threw", "first.disabling", "resource.disposed")); + Add("Q43", "S2", "failures-aggregated", + "in the last enable that was disabled, the aggregated cleanup failure is logged with its template only", + static evidence => Lifecycle(evidence, static line => line.StartsWith("log\tWarning\t", StringComparison.Ordinal) && + line.EndsWith("completed cleanup with {FailureCount} failure(s).", StringComparison.Ordinal))); + + // Q44: the policy refusal of the opt-in capabilities. + Add("Q44", "S2", "auto-assembler-policy-refused", + "without the opt-in Client.AutoAssemblerPatches is Unavailable with a Missing policy gate and no client exists", + static evidence => evidence.Observe("capabilities-policy", static observed => + observed.Item("families", "capability", "Client.AutoAssemblerPatches") is { } patches && + patches.Text("state") == "Unavailable" && patches.Text("gates.policy") == "Missing" && + patches.Bool("policyRefusal.serviceRegistered") == false && observed.Is("processUnchanged"), "processUnchanged")); + Add("Q44", "S2", "unsafe-lua-policy-refused", "without the opt-in no unsafe Lua client exists", + static evidence => evidence.Observe("capabilities-policy", static observed => + observed.Item("families", "capability", "Client.UnsafeLuaExecution") is { } unsafeLua && + unsafeLua.Text("gates.policy") == "Missing" && unsafeLua.Bool("policyRefusal.serviceRegistered") == false, + "processUnchanged")); + Add("Q44", "S2", "patch-refused", "the harness cannot apply a patch without the opt-in", + static evidence => evidence.Observe("aa-apply-refused", + static observed => observed.Text("refusal") == "AutoAssemblerNotEnabled" && observed.Bool("optInRequested") == false, + "refusal", "optInRequested")); + + // Q45: the probe changes nothing. + Add("Q45", "S1", "probe-changes-nothing", "the capability probe leaves the scratch bytes and the opened process unchanged", + static evidence => Both(evidence.Observe("capabilities-probe", static observed => observed.Is("processUnchanged"), + "processIdBefore", "processIdAfter"), + Same(evidence, "scratch-digest-before", "scratch-digest-after"))); + Add("Q45", "S1", "no-module-injected", "no speedhack, allochook, luaclient, vehdebug or dbk module and no new module", + static evidence => Both(evidence.FromFact(SessionFacts.ForbiddenModules, static value => value.Length == 0), + evidence.FromFact(SessionFacts.ModulesUnchanged, static value => value == "true"))); + + // Q46: no scenario data in logs or debug output. + foreach (string session in (string[]) ["S1", "S2"]) + { + Add("Q46", session, "logs-clean", "no captured log event carries a declared value, an address or the script marker", + static evidence => evidence.Observe("logs", static observed => observed.Number("sensitiveHits") == 0, + "eventCount", "sensitiveHits", "dropped")); + Add("Q46", session, "debug-output-clean", "the Cheat Engine debug output carries none of the scenario values", + static evidence => evidence.FromFact(SessionFacts.DebugOutputSensitiveHits, static value => value == "0")); + } + + return checks; + } + + /// + /// Q05: with CHEATENGINE_SDK_IDENTIFY_ON_ENABLE=1 (the runner sets it), CheatEngine.SDK 2.0.0 writes one + /// CheatEngineSdkIdentification: line per enable through its default debug output sink, which prefixes + /// [CheatEngine.SDK.Hosting] Information: ; its plugin.assembly field is the plugin assembly's name + /// and version. Another debug line that merely names the harness, such as a load failure, is no identification. + /// + private static CheckResult IdentificationLine(SessionEvidence evidence) + { + if (evidence.DebugOutput.Length == 0) + { + return CheckResult.NotExecuted("no debug output was captured"); + } + + string[] lines = + [ + .. evidence.DebugOutput.ReplaceLineEndings("\n").Split('\n') + .Where(static line => line.StartsWith(IdentificationPrefix, StringComparison.Ordinal)) + ]; + foreach (string line in lines) + { + Dictionary fields = new(StringComparer.Ordinal); + foreach (string field in line[IdentificationPrefix.Length..].Split("; ")) + { + int equals = field.IndexOf('=', StringComparison.Ordinal); + if (equals > 0) + { + fields.TryAdd(field[..equals], field[(equals + 1)..]); + } + } + + if (fields.TryGetValue("plugin.assembly", out string? assembly) && + assembly.StartsWith(PluginBundleBuilder.HarnessAssemblyName + " ", StringComparison.Ordinal)) + { + return CheckResult.Passed($"plugin.assembly={assembly}; sdk.version={fields.GetValueOrDefault("sdk.version")}"); + } + } + + return CheckResult.Failed($"{lines.Length} identification line(s), none whose plugin.assembly is " + + PluginBundleBuilder.HarnessAssemblyName); + } + + /// + /// Q40: the status observation reports both loaded Client assemblies (Hosting, and Core, which exists only while + /// the activation does), each with the packed package version, alone or with build metadata; an observation that + /// reports neither is no evidence of the version. + /// + private static CheckResult LoadedClientVersion(SessionEvidence evidence) + { + if (evidence.Fact(SessionFacts.ClientPackageVersion) is not { } version) + { + return CheckResult.NotExecuted("the package version was not established"); + } + + if (!evidence.TryObserve("status", out Observed? observed, out CheckResult notUsable)) + { + return notUsable; + } + + Observed[] client = + [ + .. observed.Items("assemblies").Where(static item => item.Text("role") is "clientHosting" or "clientCore") + ]; + string[] roles = [.. client.Select(static item => item.Text("role") ?? string.Empty).Distinct(StringComparer.Ordinal)]; + bool versioned = client.All(item => item.Text("informationalVersion") is { } loaded && + (loaded == version || loaded.StartsWith(version + "+", StringComparison.Ordinal))); + string reported = string.Join(", ", client.Select(static item => $"{item.Text("role")}={item.Text("informationalVersion")}")); + return CheckResult.From(roles.Length == 2 && versioned, + $"package {version}; status reports {(reported.Length == 0 ? "no Client assembly" : reported)}"); + } + + /// + /// Q32 on S3: each process the driver selects through Cheat Engine (A, then B, then A again) is the one the + /// Client's next runtime reports, and each selection moves the Client's selection epoch forward; a Client + /// that kept a stale selection fails. + /// + private static CheckResult TargetSwitches(SessionEvidence evidence) + { + (string Opened, string Runtime)[] switches = + [ + ("opened-process-a", "runtime-on-a"), ("opened-process-b", "runtime-on-b"), + ("opened-process-a-again", "runtime-back-on-a") + ]; + List facts = []; + bool passed = true; + string? previousProcess = null; + long? previousEpoch = null; + foreach ((string opened, string runtime) in switches) + { + if (!evidence.TryValue(opened, out string? selected, out CheckResult notUsable) || + !evidence.TryObserve(runtime, out Observed? observed, out notUsable)) + { + return notUsable; + } + + long? processId = observed.Number("process.processId"); + long? epoch = observed.Number("process.selectionEpoch"); + passed &= processId?.ToString(CultureInfo.InvariantCulture) == selected && + !string.Equals(selected, previousProcess, StringComparison.Ordinal) && + epoch is not null && (previousEpoch is null || epoch > previousEpoch); + string reported = processId?.ToString(CultureInfo.InvariantCulture) ?? "absent"; + string moved = epoch?.ToString(CultureInfo.InvariantCulture) ?? "absent"; + facts.Add($"{opened}={selected}: {runtime} process {reported}, epoch {moved}"); + previousProcess = selected; + previousEpoch = epoch; + } + + return CheckResult.From(passed, string.Join("; ", facts)); + } + + /// + /// Q25: every case with an expectation meets it, and the two discriminating cases (the 3-decimal text 3.142 of + /// 3.14159, as float and as double) agree. Their verdict names the rule: ordinary rounding finds the probe, the + /// range of the rtRounded documentation (up to half a unit above the text) does not. Either rule passes; + /// the receipt names it, so the remarks of ValueScanValue can follow the recorded run. + /// + private static CheckResult DecimalTolerance(SessionEvidence evidence) + { + if (!evidence.TryObserve("value-scan-decimals", out Observed? observed, out CheckResult notUsable)) + { + return notUsable; + } + + IReadOnlyList cases = observed.Items("cases"); + Observed[] discriminating = [.. cases.Where(static item => item.Is("discriminating"))]; + if (cases.Count != 12 || discriminating.Length != 2 || + !cases.All(static item => item.Is("scanned") && item.Bool("found") is not null)) + { + return CheckResult.Failed($"value-scan-decimals: {cases.Count} cases, {discriminating.Length} discriminating; " + + "every case must be scanned and read back"); + } + + string[] unmet = + [ + .. cases.Where(static item => !item.Is("discriminating") && + (item.Bool("expectedFound") is not { } expected || item.Bool("found") != expected)) + .Select(static item => $"{item.Text("type")} with {item.Number("decimals")} decimals") + ]; + bool? found = discriminating[0].Bool("found"); + bool agree = discriminating[1].Bool("found") == found; + string rule = !agree + ? "float and double disagree on 3.142" + : found == true + ? "ordinary rounding (3.142 finds 3.14159)" + : "the documented range up to half a unit above the text (3.142 does not find 3.14159)"; + string expectations = unmet.Length == 0 ? "every expectation met" : "unmet: " + string.Join(", ", unmet); + return CheckResult.From(unmet.Length == 0 && agree, $"value-scan-decimals: {expectations}; rule: {rule}"); + } + + private static bool Assembled(Observed observed, string id, string bytesPrefix) + { + return observed.Item("assembled", "id", id) is { } item && item.Is("assembled") && + item.Text("bytes")?.StartsWith(bytesPrefix, StringComparison.Ordinal) == true; + } + + /// Both results: Failed when one failed, NotExecuted when one did not run, Passed otherwise. + private static CheckResult Both(CheckResult first, CheckResult second) + { + string observation = first.Observation + " | " + second.Observation; + if (first.Status == ReceiptStatus.Failed || second.Status == ReceiptStatus.Failed) + { + return CheckResult.Failed(observation); + } + + return first.Status == ReceiptStatus.NotExecuted || second.Status == ReceiptStatus.NotExecuted + ? CheckResult.NotExecuted(observation) + : CheckResult.Passed(observation); + } + + private static CheckResult Same(SessionEvidence evidence, string before, string after) + { + TranscriptRecord? first = evidence.Transcript.Find(before); + TranscriptRecord? second = evidence.Transcript.Find(after); + if (first is not { Status: TranscriptStatus.Ok } || second is not { Status: TranscriptStatus.Ok }) + { + return CheckResult.NotExecuted($"{before} or {after} did not run"); + } + + return CheckResult.From(string.Equals(first.Value, second.Value, StringComparison.Ordinal), + $"{before}={first.Value}; {after}={second.Value}"); + } + + /// The lines of the last disabled enable hold one matching . + private static CheckResult Lifecycle(SessionEvidence evidence, Func matches) + { + if (LastDisabledEnable(evidence.Lifecycle) is not { } enable) + { + return CheckResult.NotExecuted(NoDisable); + } + + return CheckResult.From(enable.Any(matches), $"{enable[0][LedgerPrefixLength(enable[0])..]}: {enable.Count} lifecycle lines"); + } + + /// The ledger of the last disabled enable holds these stages, each a whole stage, in this order. + private static CheckResult LifecycleOrder(SessionEvidence evidence, params string[] stages) + { + if (LastDisabledEnable(evidence.Lifecycle) is not { } enable) + { + return CheckResult.NotExecuted(NoDisable); + } + + string configured = enable[0][LedgerPrefixLength(enable[0])..]; + int position = 0; + foreach (string stage in stages) + { + int found = -1; + for (int index = position; index < enable.Count; index++) + { + if (string.Equals(LedgerStage(enable[index]), stage, StringComparison.Ordinal)) + { + found = index; + break; + } + } + + if (found < 0) + { + return CheckResult.Failed($"{configured}: no '{stage}' after the earlier stages ({enable.Count} lines)"); + } + + position = found + 1; + } + + return CheckResult.Passed($"{configured}: stages in order: {string.Join(", ", stages)}"); + } + + /// + /// The lifecycle sink lines of the last enable that recorded a disable (plugin.disabling), from its + /// configure ledger entry, which every enable writes first, to the next one; a disable of an earlier enable, + /// or its log lines, never count for a later one. + /// + private static List? LastDisabledEnable(IReadOnlyList lifecycle) + { + List? last = null; + List? current = null; + foreach (string line in lifecycle) + { + if (string.Equals(LedgerStage(line), "configure", StringComparison.Ordinal)) + { + last = Disabled(current) ?? last; + current = [line]; + } + else + { + current?.Add(line); + } + } + + return Disabled(current) ?? last; + + static List? Disabled(List? enable) + { + return enable?.Any(static line => string.Equals(LedgerStage(line), "plugin.disabling", StringComparison.Ordinal)) == true + ? enable + : null; + } + } + + /// The stage of a ledger line, ledger<TAB>#<enable> <stage>[ <detail>], or . + private static string? LedgerStage(string line) + { + int start = LedgerPrefixLength(line); + if (start == 0) + { + return null; + } + + int end = line.IndexOf(' ', start); + return end < 0 ? line[start..] : line[start..end]; + } + + /// The length of ledger<TAB>#<enable> at the start of a ledger line, or 0. + private static int LedgerPrefixLength(string line) + { + if (!line.StartsWith("ledger\t#", StringComparison.Ordinal)) + { + return 0; + } + + int space = line.IndexOf(' ', "ledger\t#".Length); + return space < 0 ? 0 : space + 1; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/ScratchRegistryKey.cs b/tests/CheatEngine.Client.Tests/LiveQualification/ScratchRegistryKey.cs new file mode 100644 index 0000000..d2dbdd6 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/ScratchRegistryKey.cs @@ -0,0 +1,82 @@ +using System.Runtime.Versioning; + +using Microsoft.Win32; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The serial collection of the tests that write the registry. They only ever touch their own scratch key, but they +/// share its parent, which each of them removes once it is empty. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ScratchRegistrySerialGroup +{ + /// The collection name. + public const string Name = "Registry scratch key"; +} + +/// +/// A test-owned key HKCU\Software\CheatEngine.Client.Tests\<guid>, the only registry location a CI test +/// writes. Disposing it deletes the key tree, then deletes the parent HKCU\Software\CheatEngine.Client.Tests when +/// no other test process holds a key under it. HKCU\Software\Cheat Engine is never opened. +/// +[SupportedOSPlatform("windows")] +internal sealed class ScratchRegistryKey : IDisposable +{ + internal ScratchRegistryKey() + { + SubKey = CheatEngineUserStateLocations.ScratchRegistryParent + "\\" + Guid.NewGuid().ToString("N"); + CheatEngineUserStateLocations.RequireGuardedSubKey(SubKey); + } + + /// The scratch key, below HKEY_CURRENT_USER. + internal string SubKey + { + get; + } + + /// Whether the parent key currently exists. + internal static bool ParentExists() + { + using RegistryKey? parent = Registry.CurrentUser.OpenSubKey(CheatEngineUserStateLocations.ScratchRegistryParent, false); + return parent is not null; + } + + /// Creates (or opens) the scratch key, or one of its subkeys, for writing. + internal RegistryKey Create(string? child = null) + { + return Registry.CurrentUser.CreateSubKey(child is null ? SubKey : SubKey + "\\" + child, true); + } + + /// Whether the scratch key exists. + internal bool Exists() + { + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(SubKey, false); + return key is not null; + } + + /// + public void Dispose() + { + Registry.CurrentUser.DeleteSubKeyTree(SubKey, false); + bool empty; + using (RegistryKey? parent = Registry.CurrentUser.OpenSubKey(CheatEngineUserStateLocations.ScratchRegistryParent, false)) + { + empty = parent is not null && parent.SubKeyCount == 0 && parent.ValueCount == 0; + } + + if (!empty) + { + return; + } + + try + { + Registry.CurrentUser.DeleteSubKey(CheatEngineUserStateLocations.ScratchRegistryParent, false); + } + catch (InvalidOperationException) + { + // Another test process created its own scratch key in the meantime; it removes the parent itself. + } + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/SessionEvidence.cs b/tests/CheatEngine.Client.Tests/LiveQualification/SessionEvidence.cs new file mode 100644 index 0000000..757fe5d --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/SessionEvidence.cs @@ -0,0 +1,353 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// The verdict of one check, with what the run observed. +/// Passed, Failed or NotExecuted; NotExecuted never counts as a pass. +/// The observed facts the verdict rests on. +internal readonly record struct CheckResult(ReceiptStatus Status, string Observation) +{ + internal static CheckResult Passed(string observation) + { + return new CheckResult(ReceiptStatus.Passed, observation); + } + + internal static CheckResult Failed(string observation) + { + return new CheckResult(ReceiptStatus.Failed, observation); + } + + internal static CheckResult NotExecuted(string observation) + { + return new CheckResult(ReceiptStatus.NotExecuted, observation); + } + + internal static CheckResult From(bool passed, string observation) + { + return passed ? Passed(observation) : Failed(observation); + } +} + +/// The facts the runner establishes itself, next to the driver transcript, by name. +internal static class SessionFacts +{ + /// Whether every target's module list is the same after the session as at its start (true/false). + internal const string ModulesUnchanged = "modules-unchanged"; + + /// The forbidden modules (speedhack, allochook, luaclient, vehdebug, dbk) found in a target, comma separated. + internal const string ForbiddenModules = "forbidden-modules"; + + /// How many declared scenario values the Cheat Engine debug output contains. + internal const string DebugOutputSensitiveHits = "debug-output-sensitive-hits"; + + /// Whether every CheatEngine.Client*.dll of the harness bundle equals the packed package's (true/false). + internal const string BundleClientAssembliesMatch = "bundle-client-assemblies-match"; + + /// The lower-case SHA-256 of the native bridge deployed with the plugin. + internal const string BundleBridgeSha256 = "bundle-bridge-sha256"; + + /// The lower-case SHA-256 of the bridge the reviewed CheatEngine.SDK package ships. + internal const string ExpectedBridgeSha256 = "expected-bridge-sha256"; + + /// + /// The CheatEngine.SDK content hash the instantiated template's restore recorded: its lock file, or + /// project.assets.json when the template writes no lock file. + /// + internal const string TemplateSdkContentHash = "template-sdk-content-hash"; + + /// The content hash of the reviewed CheatEngine.SDK package. + internal const string ExpectedSdkContentHash = "expected-sdk-content-hash"; + + /// How many paths of the build workspace the template bundle's deps.json holds. + internal const string TemplateDepsWorkspacePaths = "template-deps-workspace-paths"; + + /// The Client package version the plugins were built from. + internal const string ClientPackageVersion = "client-package-version"; +} + +/// +/// Everything one session produced that the evaluators read: the driver transcript, the Cheat Engine debug output, +/// the harness's lifecycle sink and the facts the runner established itself. +/// +/// The parsed driver transcript. +/// The Cheat Engine process's debug output. +/// The lines of the harness's lifecycle receipt sink (Q43). +/// The runner's facts, by name. +[SupportedOSPlatform("windows")] +internal sealed record SessionEvidence( + Transcript Transcript, + string DebugOutput, + IReadOnlyList Lifecycle, + IReadOnlyDictionary Facts) +{ + /// The observation of a driver step whose value is a harness JSON observation. + internal CheckResult Observe(string step, Func passes, params string[] describe) + { + ArgumentNullException.ThrowIfNull(passes); + if (!TryRecord(step, out TranscriptRecord? record, out CheckResult notUsable)) + { + return notUsable; + } + + if (!Observed.TryParse(record.Value, out Observed? observed)) + { + return CheckResult.Failed($"{step}: not a JSON observation: {Shorten(record.Value)}"); + } + + string description = $"{step}: {observed.Describe(describe)}"; + return CheckResult.From(passes(observed), description); + } + + /// The harness JSON observation of a driver step, or the result that says why it cannot be used. + internal bool TryObserve(string step, [NotNullWhen(true)] out Observed? observed, out CheckResult notUsable) + { + observed = null; + if (!TryRecord(step, out TranscriptRecord? record, out notUsable)) + { + return false; + } + + if (Observed.TryParse(record.Value, out observed)) + { + return true; + } + + notUsable = CheckResult.Failed($"{step}: not a JSON observation: {Shorten(record.Value)}"); + return false; + } + + /// The plain value of a driver step, or the result that says why it cannot be used. + internal bool TryValue(string step, [NotNullWhen(true)] out string? value, out CheckResult notUsable) + { + value = TryRecord(step, out TranscriptRecord? record, out notUsable) ? record.Value : null; + return value is not null; + } + + /// The plain value of a driver step (a Lua setup or check step). + internal CheckResult Value(string step, Func passes) + { + ArgumentNullException.ThrowIfNull(passes); + return TryRecord(step, out TranscriptRecord? record, out CheckResult notUsable) + ? CheckResult.From(passes(record.Value), $"{step}: {Shorten(record.Value)}") + : notUsable; + } + + /// A driver step that must raise a Lua error (a refused marshalling, a call of a dead function). + internal CheckResult ExpectError(string step) + { + TranscriptRecord? record = Transcript.Find(step); + return record switch + { + null => CheckResult.NotExecuted($"{step}: not reached"), + { Status: TranscriptStatus.NotExecuted } => CheckResult.NotExecuted($"{step}: {Shorten(record.Value)}"), + { Status: TranscriptStatus.Error } => CheckResult.Passed($"{step}: raised {Shorten(record.Value)}"), + _ => CheckResult.Failed($"{step}: returned {Shorten(record.Value)}") + }; + } + + /// + /// A check that needs operator steps first (plugin toggles through Settings > Plugins, plan A12): + /// only when the driver recorded each of them ok, that is when it observed the + /// toggle's effect or the operator confirmed a toggle whose effect it cannot observe; otherwise NotExecuted, with + /// what the driver recorded (skipped, no action in time, or a prompt that failed). + /// + internal CheckResult AfterOperator(IReadOnlyList operatorSteps, Func then) + { + ArgumentNullException.ThrowIfNull(operatorSteps); + ArgumentNullException.ThrowIfNull(then); + foreach (string operatorStep in operatorSteps) + { + TranscriptRecord? record = Transcript.Find(operatorStep); + if (record is null) + { + return CheckResult.NotExecuted($"{operatorStep}: not reached"); + } + + if (record.Status != TranscriptStatus.Ok) + { + return CheckResult.NotExecuted($"{operatorStep} was not performed ({record.Status}: " + + $"{Shorten(record.Value)}); the Settings > Plugins toggle is operator work"); + } + } + + return then(); + } + + /// A fact the runner established, or . + internal string? Fact(string name) + { + return Facts.TryGetValue(name, out string? value) ? value : null; + } + + /// A check of one runner fact. + internal CheckResult FromFact(string name, Func passes) + { + ArgumentNullException.ThrowIfNull(passes); + return Fact(name) is { } value + ? CheckResult.From(passes(value), $"{name}={Shorten(value)}") + : CheckResult.NotExecuted($"{name} was not established"); + } + + /// Cuts a transcript value to a receipt-sized observation. + internal static string Shorten(string value) + { + ArgumentNullException.ThrowIfNull(value); + string flat = value.ReplaceLineEndings(" "); + return flat.Length <= 240 ? flat : string.Concat(flat.AsSpan(0, 240), "…"); + } + + private bool TryRecord(string step, [NotNullWhen(true)] out TranscriptRecord? record, out CheckResult notUsable) + { + record = Transcript.Find(step); + notUsable = record switch + { + null => CheckResult.NotExecuted($"{step}: not reached"), + { Status: TranscriptStatus.NotExecuted } => CheckResult.NotExecuted($"{step}: {Shorten(record.Value)}"), + { Status: TranscriptStatus.Error } => CheckResult.Failed($"{step}: error {Shorten(record.Value)}"), + _ => default + }; + return record is { Status: TranscriptStatus.Ok }; + } +} + +/// A parsed harness observation, read by dotted paths (checks.filterExact). +internal sealed class Observed +{ + private readonly JsonElement _root; + + private Observed(JsonElement root) + { + _root = root; + } + + /// Parses a JSON object observation. + internal static bool TryParse(string text, [NotNullWhen(true)] out Observed? observed) + { + observed = null; + try + { + using JsonDocument document = JsonDocument.Parse(text); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + observed = new Observed(document.RootElement.Clone()); + return true; + } + catch (JsonException) + { + return false; + } + } + + /// The element at a dotted path, or . + internal JsonElement? Element(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + JsonElement current = _root; + foreach (string segment in path.Split('.')) + { + if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out current)) + { + return null; + } + } + + return current; + } + + /// The boolean at a path, or when it is absent or not a boolean. + internal bool? Bool(string path) + { + return Element(path) is { ValueKind: JsonValueKind.True or JsonValueKind.False } element ? element.GetBoolean() : null; + } + + /// Whether the boolean at a path is . + internal bool Is(string path) + { + return Bool(path) == true; + } + + /// The string at a path, or . + internal string? Text(string path) + { + return Element(path) is { ValueKind: JsonValueKind.String } element ? element.GetString() : null; + } + + /// The integer at a path, or . + internal long? Number(string path) + { + return Element(path) is { ValueKind: JsonValueKind.Number } element && element.TryGetInt64(out long value) + ? value + : null; + } + + /// The item of an array whose equals . + internal Observed? Item(string arrayPath, string key, string value) + { + if (Element(arrayPath) is not { ValueKind: JsonValueKind.Array } array) + { + return null; + } + + foreach (JsonElement item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty(key, out JsonElement found) && + found.ValueKind == JsonValueKind.String && string.Equals(found.GetString(), value, StringComparison.Ordinal)) + { + return new Observed(item); + } + } + + return null; + } + + /// Every object item of an array. + internal IReadOnlyList Items(string arrayPath) + { + return Element(arrayPath) is { ValueKind: JsonValueKind.Array } array + ? [.. array.EnumerateArray().Where(static item => item.ValueKind == JsonValueKind.Object).Select(static item => new Observed(item))] + : []; + } + + /// The values at , as path=value pairs. + internal string Describe(IReadOnlyList paths) + { + ArgumentNullException.ThrowIfNull(paths); + if (paths.Count == 0) + { + return "observed"; + } + + StringBuilder text = new(); + foreach (string path in paths) + { + if (text.Length > 0) + { + text.Append("; "); + } + + text.Append(path).Append('=').Append(Element(path) is { } element ? Render(element) : "absent"); + } + + return text.ToString(); + } + + private static string Render(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + JsonValueKind.Number => element.GetRawText(), + _ => string.Create(CultureInfo.InvariantCulture, $"<{element.ValueKind}>") + }; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/SessionPlanTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/SessionPlanTests.cs new file mode 100644 index 0000000..62caded --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/SessionPlanTests.cs @@ -0,0 +1,308 @@ +using System.Runtime.Versioning; +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Tests.Infrastructure; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The session plans S1 to S6 as reviewed data: each driver renders with valid, unique steps in the reviewed order, +/// calls only functions the harness declares, never attempts an unproven plugin toggle, and names every step its +/// checks read, and each session's setup matches what its scenarios need. +/// +[SupportedOSPlatform("windows")] +public sealed partial class SessionPlanTests +{ + private const string TranscriptPath = @"C:\runs\20260925T101530Z-a1b2\sessions\S1\transcript.txt"; + + /// The reviewed step order of every session. + private static readonly Dictionary ReviewedSteps = new(StringComparer.Ordinal) + { + ["S1"] = "main-form open-process opened-process scratch-allocation load-plugin harness-ready status runtime " + + "scratch-digest-before capabilities-probe scratch-digest-after target-declare roundtrip-bytes-with-nul " + + "roundtrip-utf8-multibyte roundtrip-utf16-with-nul roundtrip-int32-minus-one roundtrip-uint32-max " + + "roundtrip-int64-limits roundtrip-address-above-4gib batch-partial pointer-size-4 runtime-pointer-4 " + + "pointer-width-refusal pointer-size-8 runtime-pointer-8 instructions aob-known aob-absent aob-module " + + "aob-module-absent aob-limit aob-cancel value-scan-first value-scan-next value-scan-reset value-scan-release " + + "value-scan-decimals allocation-allocate allocation-state allocation-release aa-check aa-apply aa-release " + + "aa-apply-failing table-create table-destroy-record table-probe-destroyed table-create-again table-save " + + "table-load table-probe-loaded table-save-outside symbol-register symbol-state-registered symbol-release " + + "symbol-state-released worker-start worker-result worker-probe-global integer-exact integer-float-below " + + "integer-float-2p53 address-exact address-float-2p53 logs settings-probe clear-address-list", + ["S2"] = "main-form open-process opened-process load-plugin harness-ready status capabilities-policy " + + "aa-apply-refused keep-function toggle-disable kept-function-after-disable toggle-enable " + + "status-after-reenable write-configure-fault toggle-disable-for-fault toggle-enable-faulted " + + "restore-disabling-fault toggle-enable-after-fault status-after-fault logs clear-address-list", + ["S3"] = "main-form open-process-a opened-process-a scratch-allocation load-plugin harness-ready target-declare " + + "runtime-on-a allocation-allocate value-scan-first aa-apply open-process-b opened-process-b runtime-on-b " + + "allocation-state-on-b allocation-release-on-b value-scan-state-on-b value-scan-release-on-b aa-state-on-b " + + "aa-release-on-b open-process-a-again opened-process-a-again runtime-back-on-a allocation-state-back-on-a " + + "allocation-allocate-new allocation-release-new open-file-as-process runtime-file-as-process " + + "allocation-unidentified value-scan-unidentified aob-module-unidentified clear-address-list", + ["S4"] = "main-form open-process opened-process scratch-allocation load-plugin harness-ready target-declare runtime " + + "roundtrip-int64-limits roundtrip-address-above-4gib aob-module aob-module-absent instructions " + + "clear-address-list", + ["S5a"] = "main-form load-plugin-a a-ready load-neighbour neighbour-ready load-plugin-b b-ready a-identity b-identity " + + "neighbour-identity a-ping b-ping a-collision-before load-collision a-collision-after third-party-replace " + + "toggle-disable-a third-party-survives b-ping-after-a-disabled toggle-disable-b clear-address-list", + ["S5b"] = "main-form load-neighbour neighbour-ready load-plugin-a a-ready load-plugin-b b-ready a-identity b-identity " + + "neighbour-identity a-ping b-ping a-collision-before load-collision a-collision-after third-party-replace " + + "toggle-disable-a third-party-survives b-ping-after-a-disabled toggle-disable-b clear-address-list", + ["S6"] = "main-form load-template template-ready template-status clear-address-list" + }; + + /// + /// The steps of every session that no check reads: the Cheat Engine-level preparation (opening a target, the + /// scratch region, loading a plugin, the leases S3 makes on A before the switch), and the spike's read-only + /// settings probe. Every other step must change a check's verdict when it is missing. + /// + private static readonly Dictionary SetupSteps = new(StringComparer.Ordinal) + { + ["S1"] = "main-form open-process opened-process scratch-allocation load-plugin harness-ready target-declare " + + "pointer-size-4 pointer-size-8 table-create table-destroy-record table-create-again worker-start " + + "settings-probe clear-address-list", + ["S2"] = "main-form open-process opened-process load-plugin harness-ready status keep-function write-configure-fault " + + "restore-disabling-fault clear-address-list", + ["S3"] = "main-form open-process-a scratch-allocation load-plugin harness-ready target-declare allocation-allocate " + + "value-scan-first aa-apply open-process-b open-process-a-again allocation-allocate-new open-file-as-process " + + "clear-address-list", + ["S4"] = "main-form open-process opened-process scratch-allocation load-plugin harness-ready target-declare " + + "clear-address-list", + ["S5a"] = "main-form load-plugin-a a-ready load-neighbour neighbour-ready load-plugin-b b-ready load-collision " + + "third-party-replace clear-address-list", + ["S5b"] = "main-form load-plugin-a a-ready load-neighbour neighbour-ready load-plugin-b b-ready load-collision " + + "third-party-replace clear-address-list", + ["S6"] = "main-form load-template template-ready clear-address-list" + }; + + [Fact] + public void EverySessionRendersItsReviewedSteps() + { + Assert.Equal(ReviewedSteps.Keys.Order(StringComparer.Ordinal), SessionPlans.All.Select(static plan => plan.Session)); + foreach (QualificationSessionPlan plan in SessionPlans.All) + { + IReadOnlyList steps = plan.Driver(Context(plan)); + string rendered = LuaDriverScript.RenderSteps(plan.Session, TranscriptPath, steps); + + Assert.Equal(ReviewedSteps[plan.Session], string.Join(' ', steps.Select(static step => step.Name))); + Assert.EndsWith("driver.Enabled = true\n", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("\r", rendered, StringComparison.Ordinal); + } + } + + [Fact] + public void DriversCallOnlyFunctionsTheHarnessDeclares() + { + string functions = File.ReadAllText(RepositoryLayout.Combine( + "tests/CheatEngine.Client.LivePlugin.Qualification/QualificationLuaFunctions.cs")); + foreach (QualificationSessionPlan plan in SessionPlans.All) + { + foreach (LuaDriverStep step in plan.Driver(Context(plan))) + { + foreach (Match call in HarnessCall().Matches(step.Body)) + { + Assert.Contains($"[LuaFunction(\"{call.Groups["name"].Value}\")]", functions, StringComparison.Ordinal); + } + } + } + } + + [Fact] + public void PluginTogglesAreOperatorStepsNeverAttempted() + { + foreach (QualificationSessionPlan plan in SessionPlans.All) + { + IReadOnlyList steps = plan.Driver(Context(plan)); + Assert.All(steps.Where(static step => step.Name.StartsWith("toggle-", StringComparison.Ordinal)), static step => + { + Assert.Equal(LuaDriverStepKind.Operator, step.Kind); + Assert.StartsWith("Operator: in Edit > Settings > Plugins, ", step.Prompt, StringComparison.Ordinal); + Assert.Equal(LuaDriverScript.OperatorAttempts, step.Attempts); + }); + Assert.All(steps.Where(static step => step.Kind == LuaDriverStepKind.Operator), + static step => Assert.StartsWith("toggle-", step.Name, StringComparison.Ordinal)); + string rendered = LuaDriverScript.RenderSteps(plan.Session, TranscriptPath, steps); + Assert.DoesNotContain("Checked", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("ModalResult", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("getSettingsForm", string.Concat(steps.Where(static step => step.Kind == LuaDriverStepKind.Operator) + .Select(static step => step.Body)), StringComparison.Ordinal); + } + } + + [Fact] + public void AToggleEndsWhenThePluginsFunctionIsGoneOrBackAndAFailingEnableOnConfirmation() + { + LuaDriverStep disable = LuaDriverSteps.Toggle("toggle-disable", false, "Plugin", "plugin_status"); + LuaDriverStep enable = LuaDriverSteps.Toggle("toggle-enable", true, "Plugin", "plugin_status", "Note."); + LuaDriverStep failing = LuaDriverSteps.ConfirmedToggle("toggle-enable-faulted", true, "Plugin", "It must fail."); + + Assert.Equal(""" + if type(_G["plugin_status"]) == "function" then return nil end + return "disabled" + """.ReplaceLineEndings("\n"), disable.Body.ReplaceLineEndings("\n")); + Assert.Equal(""" + if type(_G["plugin_status"]) ~= "function" then return nil end + return "enabled" + """.ReplaceLineEndings("\n"), enable.Body.ReplaceLineEndings("\n")); + Assert.Equal("Operator: in Edit > Settings > Plugins, tick 'Plugin' and press OK. Note. The driver continues once " + + "the plugin's functions are back.", enable.Prompt); + Assert.Empty(failing.Body); + Assert.Equal("Operator: in Edit > Settings > Plugins, tick 'Plugin' and press OK. It must fail. Then press Done.", + failing.Prompt); + string rendered = LuaDriverScript.RenderSteps("S2", TranscriptPath, [disable, failing]); + Assert.Contains(""" { name = "toggle-enable-faulted", kind = "operator", attempts = 360, prompt = "Operator: in Edit > Settings > Plugins, tick 'Plugin' and press OK. It must fail. Then press Done." },""", + rendered, StringComparison.Ordinal); + Assert.Contains(""" { name = "toggle-disable", kind = "operator", attempts = 360, prompt = "Operator: in Edit > Settings > Plugins, untick 'Plugin' and press OK. The driver continues once the plugin's functions are gone.", run = function()""", + rendered, StringComparison.Ordinal); + } + + [Fact] + public void EveryStepACheckReadsIsInItsSessionsDriver() + { + foreach (QualificationSessionPlan plan in SessionPlans.All) + { + IReadOnlyList steps = plan.Driver(Context(plan)); + StringBuilder transcript = new(); + foreach (LuaDriverStep step in steps) + { + transcript.Append("R\t").Append(step.Name).Append("\tok\t\"{}\"\n"); + } + + SessionEvidence evidence = new(TranscriptParser.Parse(Encoding.UTF8.GetBytes(transcript.Append("DONE\n").ToString())), + string.Empty, [], new Dictionary(StringComparer.Ordinal)); + foreach (QualificationCheck check in plan.Checks) + { + CheckResult result = check.Evaluate(evidence); + Assert.False(result.Observation.Contains("not reached", StringComparison.Ordinal), + $"{plan.Session} {check.Scenario}/{check.Name} reads a step the driver does not have: {result.Observation}"); + } + } + } + + [Fact] + public void EveryDriverStepIsReadByACheckOrIsReviewedSetup() + { + Assert.Equal(ReviewedSteps.Keys.Order(StringComparer.Ordinal), SetupSteps.Keys.Order(StringComparer.Ordinal)); + foreach (QualificationSessionPlan plan in SessionPlans.All) + { + string[] steps = [.. plan.Driver(Context(plan)).Select(static step => step.Name)]; + string[] verdicts = Verdicts(plan, steps, null); + + // A step is read when removing it from the transcript changes the verdict or the observation of a check. + string[] unread = [.. steps.Where(step => Verdicts(plan, steps, step).SequenceEqual(verdicts, StringComparer.Ordinal))]; + + Assert.Equal(SetupSteps[plan.Session].Split(' ').Order(StringComparer.Ordinal), unread.Order(StringComparer.Ordinal)); + } + } + + [Fact] + public void SessionSetupsMatchWhatTheirScenariosNeed() + { + Assert.True(SessionPlans.S1.Setup is { EnableAutoAssembler: true, TableRoot: true, LifecycleSink: true, AuthorizedRole: "A" }); + Assert.True(SessionPlans.S2.Setup is { EnableAutoAssembler: false, LifecycleSink: true, Fault: FaultStage.ModuleOnDisabling }); + Assert.True(SessionPlans.S3.Setup is { EnableAutoAssembler: true, FileAsProcessCopy: true, AuthorizedRole: "A" }); + Assert.Equal(["A", "B"], SessionPlans.S3.Setup.Targets.Select(static target => target.Role)); + Assert.All(SessionPlans.S3.Setup.Targets, static target => Assert.Equal(CheatEngineProfile.Target64, target.Executable)); + Assert.Equal(CheatEngineProfile.Target32, Assert.Single(SessionPlans.S4.Setup.Targets).Executable); + Assert.Equal(SessionPlans.S5a.Setup.Bundles, SessionPlans.S5b.Setup.Bundles); + Assert.Empty(SessionPlans.S5a.Setup.Targets); + Assert.Empty(SessionPlans.S5b.Setup.Targets); + Assert.DoesNotContain(SessionBundle.Harness, SessionPlans.S5a.Setup.Bundles); + Assert.Null(SessionPlans.S5a.Setup.AuthorizedRole); + Assert.Equal([SessionBundle.Template], SessionPlans.S6.Setup.Bundles); + Assert.All(SessionPlans.All.Where(static plan => plan.Setup.Bundles.Contains(SessionBundle.Harness)), + static plan => Assert.Equal("A", plan.Setup.AuthorizedRole)); + } + + [Fact] + public void TheConfigureFaultIsFollowedByTheSessionsOwnDisablingFault() + { + IReadOnlyList steps = SessionPlans.S2.Driver(Context(SessionPlans.S2)); + + Assert.Contains("\\\"throwIn\\\": \\\"Configure\\\"", Assert.Single(steps, static step => step.Name == "write-configure-fault").Body, + StringComparison.Ordinal); + Assert.Contains($"\\\"throwIn\\\": \\\"{SessionPlans.S2.Setup.Fault}\\\"", + Assert.Single(steps, static step => step.Name == "restore-disabling-fault").Body, StringComparison.Ordinal); + Assert.DoesNotContain("os.remove", string.Concat(steps.Select(static step => step.Body)), StringComparison.Ordinal); + } + + [Fact] + public void TheTwoCoexistenceOrdersDifferOnlyInTheirLoadOrder() + { + string[] first = [.. ReviewedSteps["S5a"].Split(' ').Order(StringComparer.Ordinal)]; + string[] second = [.. ReviewedSteps["S5b"].Split(' ').Order(StringComparer.Ordinal)]; + + Assert.Equal(first, second); + Assert.NotEqual(ReviewedSteps["S5a"], ReviewedSteps["S5b"]); + } + + [Fact] + public void DriverStepsRefuseDuplicateNames() + { + LuaDriverStep step = LuaDriverSteps.Lua("same", "return 1"); + + Assert.Throws(() => LuaDriverScript.RenderSteps("S1", TranscriptPath, [step, step])); + } + + [Fact] + public void TheWorkerPollStopsOnlyWhenTheObservationIsNoLongerPending() + { + LuaDriverStep poll = LuaDriverSteps.Poll("worker-result", "worker_admission", 40, LuaLiteral.String("result")); + + Assert.Equal(LuaDriverStepKind.Poll, poll.Kind); + Assert.Equal(""" + local harness = _G["cheatengine_client_qualification_worker_admission"] + if type(harness) ~= "function" then error("the harness does not define " .. "cheatengine_client_qualification_worker_admission") end + local value = harness("result") + if string.find(value, "\"pending\":true", 1, true) ~= nil then return nil end + return value + """.ReplaceLineEndings("\n"), poll.Body.ReplaceLineEndings("\n")); + } + + [Fact] + public void PatternsAreTheFileHeaderAndSixteenRandomBytes() + { + using TemporaryDirectory temporary = new("SessionPatterns"); + string image = Path.Combine(temporary.CreateDirectory("image"), "target.exe"); + File.WriteAllBytes(image, [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04]); + + Assert.Equal("4D 5A 90 00 03 00 00 00", LiveSandboxSession.HeaderPattern(image)); + Assert.Matches("^([0-9A-F]{2} ){15}[0-9A-F]{2}$", LiveSandboxSession.RandomPattern()); + Assert.Equal(4, LiveSandboxSession.CountValues("a MARKER, marker and 0x1E2880 1e2880", ["marker", "1E2880", "ab"])); + } + + private static string[] Verdicts(QualificationSessionPlan plan, IEnumerable steps, string? without) + { + StringBuilder transcript = new(); + foreach (string step in steps.Where(step => step != without)) + { + transcript.Append("R\t").Append(step).Append("\tok\t\"{}\"\n"); + } + + SessionEvidence evidence = new(TranscriptParser.Parse(Encoding.UTF8.GetBytes(transcript.Append("DONE\n").ToString())), + string.Empty, [], new Dictionary(StringComparer.Ordinal)); + return [.. plan.Checks.Select(check => check.Evaluate(evidence)).Select(static result => $"{result.Status}: {result.Observation}")]; + } + + private static SessionContext Context(QualificationSessionPlan plan) + { + Dictionary processIds = new(StringComparer.Ordinal); + int next = 4242; + foreach (SessionTarget target in plan.Setup.Targets) + { + processIds[target.Role] = next++; + } + + return new SessionContext(TranscriptPath, processIds, + plan.Setup.Bundles.ToDictionary(static bundle => bundle, static bundle => $@"C:\runs\plugins\{bundle}\{bundle}.dll"), + plan.Setup.Targets.Count > 0 ? plan.Setup.Targets[0].Executable : string.Empty, "4D 5A 90 00 03 00 00 00", + "01 23 45 67 89 AB CD EF 01 23 45 67 89 AB CD EF", 0x1234_5678, 0x2345_6789, + plan.Setup.FileAsProcessCopy ? @"C:\runs\file-as-process\gtutorial-x86_64.exe" : null, "cheatengine_client_plugin_status"); + } + + [GeneratedRegex("""_G\["(?cheatengine_client_qualification_[a-z0-9_]+)"\] *$""", RegexOptions.CultureInvariant | RegexOptions.Multiline, 1000)] + private static partial Regex HarnessCall(); +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/SessionPlans.cs b/tests/CheatEngine.Client.Tests/LiveQualification/SessionPlans.cs new file mode 100644 index 0000000..d7ecbae --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/SessionPlans.cs @@ -0,0 +1,516 @@ +using System.Runtime.Versioning; + +using LivePlugin.Qualification.Harness; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// A plugin bundle a session loads, built from the packed packages (). +internal enum SessionBundle +{ + /// The qualification harness. + Harness, + + /// Coexistence Plugin A. + PluginA, + + /// Coexistence Plugin B. + PluginB, + + /// The coexistence contender that exports Plugin A's collision global. + PluginCollision, + + /// The plain CheatEngine.SDK 1.x neighbour, generated at run time (). + SdkNeighbour, + + /// The packed Templates package, instantiated as QualTemplatePlugin. + Template +} + +/// A disposable target a session starts from the sandbox. +/// The target's role in the session's steps (A, B). +/// The target image of the profile, for example . +internal sealed record SessionTarget(string Role, string Executable); + +/// What a session needs before its driver runs. +/// The targets to start. +/// The target the authorization manifest names, or for none. +/// The bundles to build. +/// Whether the harness composes the Auto Assembler opt-in (Q35). +/// Whether the harness gets a table root below the session directory (Q34). +/// Whether the harness writes the lifecycle receipt sink (Q43). +/// The harness fault switch of the session's first enable. +/// Whether the session opens a copy of the x64 target as a file (S3). +internal sealed record SessionSetup( + IReadOnlyList Targets, + string? AuthorizedRole, + IReadOnlyList Bundles, + bool EnableAutoAssembler = false, + bool TableRoot = false, + bool LifecycleSink = false, + FaultStage Fault = FaultStage.None, + bool FileAsProcessCopy = false); + +/// The run-time values a session's driver needs. +/// Where the driver writes its transcript. +/// The started targets, by role. +/// The plugin assemblies, by bundle. +/// The file name of the module the module-scoped scans use. +/// The first 8 bytes of that module's file, as an AOB pattern (Q27, Q28). +/// A random 16-byte pattern (Q27, Q28). +/// The value-scan marker of the first scan (Q25). +/// The value-scan marker of the next scan (Q26). +/// The target copy S3 opens as a file, or . +/// The Lua global the instantiated template exports (S6). +internal sealed record SessionContext( + string TranscriptPath, + IReadOnlyDictionary ProcessIds, + IReadOnlyDictionary PluginPaths, + string TargetModule, + string ModuleHeaderPattern, + string AbsentPattern, + int FirstMarker, + int NextMarker, + string? FileAsProcessPath, + string TemplateStatusGlobal) +{ + /// The process id of the target in . + internal int ProcessId(string role) + { + return ProcessIds.TryGetValue(role, out int processId) + ? processId + : throw new InvalidOperationException($"The session started no target '{role}'."); + } + + /// The plugin assembly of . + internal string PluginPath(SessionBundle bundle) + { + return PluginPaths.TryGetValue(bundle, out string? path) + ? path + : throw new InvalidOperationException($"The session built no {bundle} bundle."); + } +} + +/// One session of the live qualification: its setup, its driver and its checks. +/// The session id (S1 to S6; S5a and S5b are the two load orders of S5). +/// What the session establishes. +/// What the runner prepares. +/// The driver steps, from the run-time values. +[SupportedOSPlatform("windows")] +internal sealed record QualificationSessionPlan( + string Session, + string Title, + SessionSetup Setup, + Func> Driver) +{ + /// The checks the evaluators run on this session's evidence. + internal IReadOnlyList Checks => [.. ScenarioEvaluators.Checks.Where(check => check.Session == Session)]; + + /// The value of the Session trait of the live fact that runs this session. + internal string Trait => Session[..2]; +} + +/// +/// The sessions S1 to S6 of the live qualification (runner specification). Every Cheat Engine-level setup (opening +/// a target, allocating the scratch region, changing the pointer size, opening a file as a process, loading a +/// plugin) is a reviewed driver step; the Client work runs in the plugins. A plugin toggle through Settings > +/// Plugins is an operator step until the S0 spike proves that the driver can perform it (plan A12): the driver +/// prompts the operator and waits for the plugin's functions to disappear or come back, and a toggle the operator +/// skips is recorded notexecuted, so every check that needs it stays NotExecuted instead of guessing. +/// +[SupportedOSPlatform("windows")] +internal static class SessionPlans +{ + /// The scratch symbol the driver allocates and registers in the authorized target. + internal const string ScratchSymbol = LuaDriverScript.HarnessFunctionPrefix + "scratch"; + + /// The Lua global the driver keeps a harness function in, to call it after a disable (Q16, CRIT-07). + internal const string KeptFunctionGlobal = LuaDriverScript.HarnessFunctionPrefix + "driver_kept"; + + /// The allocation names of S1 and S3. + internal const string AllocationName = LuaDriverScript.HarnessFunctionPrefix + "allocation"; + + internal const string SecondAllocationName = AllocationName + "_second"; + + /// The symbol the harness registers for Q16.b. + internal const string LeasedSymbol = LuaDriverScript.HarnessFunctionPrefix + "leased_symbol"; + + /// The table files of Q34, below the session's table root. + internal const string TableFile = LuaDriverScript.HarnessFunctionPrefix + "table.CT"; + + internal const string OutsideTableFile = LuaDriverScript.HarnessFunctionPrefix + "table_outside.CT"; + + /// The harness's Plugin display name. + internal const string HarnessDisplayName = LiveSandboxSession.HarnessDisplayName; + + /// The harness function whose presence tells the driver that the harness is enabled. + internal const string HarnessStatus = LuaDriverScript.HarnessFunctionPrefix + "status"; + + internal const string PluginADisplayName = "CheatEngine.Client Coexistence Plugin A"; + internal const string PluginBDisplayName = "CheatEngine.Client Coexistence Plugin B"; + + /// The coexistence globals the S5 steps call. + internal const string AIdentity = "cheatengine_client_coexistence_a_identity"; + + internal const string APing = "cheatengine_client_coexistence_a_ping"; + internal const string ACollision = "cheatengine_client_coexistence_a_collision"; + internal const string BIdentity = "cheatengine_client_coexistence_b_identity"; + internal const string BPing = "cheatengine_client_coexistence_b_ping"; + + /// S1: the x64 core on gtutorial-x86_64, with the Auto Assembler opt-in, a table root and the sink. + internal static QualificationSessionPlan S1 + { + get; + } = new("S1", "x64 core on gtutorial-x86_64", + new SessionSetup([new SessionTarget("A", CheatEngineProfile.Target64)], "A", [SessionBundle.Harness], + EnableAutoAssembler: true, TableRoot: true, LifecycleSink: true), + static context => + [ + LuaDriverSteps.MainForm(), + .. LuaDriverSteps.OpenProcess("open-process", context.ProcessId("A")), + ScratchAllocation(), + .. LoadHarness(context), + LuaDriverSteps.Call("status", "status"), + LuaDriverSteps.Call("runtime", "runtime"), + ScratchDigest("scratch-digest-before"), + LuaDriverSteps.Call("capabilities-probe", "capabilities", LuaLiteral.Integer(1)), + ScratchDigest("scratch-digest-after"), + LuaDriverSteps.Call("target-declare", "target_declare", LuaLiteral.String(ScratchSymbol)), + .. RoundTrips("bytes-with-nul", "utf8-multibyte", "utf16-with-nul", "int32-minus-one", "uint32-max", + "int64-limits", "address-above-4gib"), + LuaDriverSteps.Call("batch-partial", "memory_batch_partial", LuaLiteral.String(ScratchSymbol), + LuaLiteral.Integer(0x10)), + LuaDriverSteps.Lua("pointer-size-4", "setPointerSize(4)\nreturn \"4\""), + LuaDriverSteps.Call("runtime-pointer-4", "runtime"), + LuaDriverSteps.Call("pointer-width-refusal", "memory_roundtrip", LuaLiteral.String("address-above-4gib"), + LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.Lua("pointer-size-8", "setPointerSize(8)\nreturn \"8\""), + LuaDriverSteps.Call("runtime-pointer-8", "runtime"), + LuaDriverSteps.Call("instructions", "instructions", LuaLiteral.String(ScratchSymbol)), + .. AobSteps(context), + LuaDriverSteps.Call("value-scan-first", "value_scan", LuaLiteral.String("first"), + LuaLiteral.String(ScratchSymbol), LuaLiteral.Integer(context.FirstMarker)), + LuaDriverSteps.Call("value-scan-next", "value_scan", LuaLiteral.String("next"), + LuaLiteral.String(ScratchSymbol), LuaLiteral.Integer(context.NextMarker)), + LuaDriverSteps.Call("value-scan-reset", "value_scan", LuaLiteral.String("reset"), + LuaLiteral.String(ScratchSymbol), LuaLiteral.Integer(0)), + LuaDriverSteps.Call("value-scan-release", "value_scan", LuaLiteral.String("release"), LuaLiteral.String(""), + LuaLiteral.Integer(0)), + LuaDriverSteps.Call("value-scan-decimals", "value_scan", LuaLiteral.String("decimals"), + LuaLiteral.String(ScratchSymbol), LuaLiteral.Integer(0)), + Allocation("allocation-allocate", "allocate", AllocationName, 64), + Allocation("allocation-state", "state", AllocationName, 0), + Allocation("allocation-release", "release", AllocationName, 0), + Patch("aa-check", "check", "benign"), + Patch("aa-apply", "apply", "benign"), + Patch("aa-release", "release", ""), + Patch("aa-apply-failing", "apply", "failing"), + LuaDriverSteps.Call("table-create", "table_create", LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.Lua("table-destroy-record", """ + local record = getAddressList().getMemoryRecordByDescription("cheatengine_client_qualification_record") + if record == nil then error("no harness record") end + record.destroy() + return "destroyed" + """), + LuaDriverSteps.Call("table-probe-destroyed", "table_probe"), + LuaDriverSteps.Call("table-create-again", "table_create", LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.Call("table-save", "table_save", LuaLiteral.String(TableFile), LuaLiteral.Integer(0)), + LuaDriverSteps.Call("table-load", "table_load", LuaLiteral.String(TableFile)), + LuaDriverSteps.Call("table-probe-loaded", "table_probe"), + LuaDriverSteps.Call("table-save-outside", "table_save", LuaLiteral.String(OutsideTableFile), + LuaLiteral.Integer(1)), + LuaDriverSteps.Call("symbol-register", "symbol_register", LuaLiteral.String(LeasedSymbol), + LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.Call("symbol-state-registered", "symbol_state", LuaLiteral.String(LeasedSymbol)), + LuaDriverSteps.Call("symbol-release", "symbol_release", LuaLiteral.String(LeasedSymbol)), + LuaDriverSteps.Call("symbol-state-released", "symbol_state", LuaLiteral.String(LeasedSymbol)), + LuaDriverSteps.Call("worker-start", "worker_admission", LuaLiteral.String("start")), + LuaDriverSteps.Poll("worker-result", "worker_admission", LuaDriverScript.ShortAttempts, + LuaLiteral.String("result")), + LuaDriverSteps.Lua("worker-probe-global", + "return tostring(type(_G[\"cheatengine_client_qualification_worker_probe\"]))"), + LuaDriverSteps.Call("integer-exact", "integer_echo", "9007199254740993"), + LuaDriverSteps.Call("integer-float-below", "integer_echo", "9007199254740991.0"), + LuaDriverSteps.Call("integer-float-2p53", "integer_echo", "2^53"), + LuaDriverSteps.Call("address-exact", "address_echo", "0x7FFFFFFFFFFF"), + LuaDriverSteps.Call("address-float-2p53", "address_echo", "2^53"), + LuaDriverSteps.Call("logs", "logs"), + LuaDriverSteps.SettingsProbe(), + LuaDriverSteps.ClearAddressList() + ]); + + /// + /// S2: lifecycle and faults, without the Auto Assembler opt-in and with the ModuleOnDisabling fault; the + /// disable at closeCE reaches the lifecycle sink. + /// + internal static QualificationSessionPlan S2 + { + get; + } = new("S2", "lifecycle and faults", + new SessionSetup([new SessionTarget("A", CheatEngineProfile.Target64)], "A", [SessionBundle.Harness], + LifecycleSink: true, Fault: FaultStage.ModuleOnDisabling), + static context => + [ + LuaDriverSteps.MainForm(), + .. LuaDriverSteps.OpenProcess("open-process", context.ProcessId("A")), + .. LoadHarness(context), + LuaDriverSteps.Call("status", "status"), + LuaDriverSteps.Call("capabilities-policy", "capabilities", LuaLiteral.Integer(0)), + Patch("aa-apply-refused", "apply", "benign"), + LuaDriverSteps.Lua("keep-function", + $"{KeptFunctionGlobal} = _G[\"cheatengine_client_qualification_status\"]\nreturn \"kept\""), + LuaDriverSteps.Toggle("toggle-disable", false, HarnessDisplayName, HarnessStatus), + LuaDriverSteps.Lua("kept-function-after-disable", $"return {KeptFunctionGlobal}()"), + LuaDriverSteps.Toggle("toggle-enable", true, HarnessDisplayName, HarnessStatus), + LuaDriverSteps.Call("status-after-reenable", "status"), + LuaDriverSteps.Lua("write-configure-fault", WriteFaultSwitch(context, FaultStage.Configure)), + LuaDriverSteps.Toggle("toggle-disable-for-fault", false, HarnessDisplayName, HarnessStatus), + LuaDriverSteps.ConfirmedToggle("toggle-enable-faulted", true, HarnessDisplayName, + "This enable must fail, because the harness's Configure throws: close the error Cheat Engine shows."), + LuaDriverSteps.Lua("restore-disabling-fault", WriteFaultSwitch(context, FaultStage.ModuleOnDisabling)), + LuaDriverSteps.Toggle("toggle-enable-after-fault", true, HarnessDisplayName, HarnessStatus, + "If it is still ticked after the failed enable, untick it and press OK first."), + LuaDriverSteps.Call("status-after-fault", "status"), + LuaDriverSteps.Call("logs", "logs"), + LuaDriverSteps.ClearAddressList() + ]); + + /// + /// S3: target identity on two gtutorial-x86_64 instances: the leases made on A after Cheat Engine selects B, back + /// on A, and on a copy opened as a file. + /// + internal static QualificationSessionPlan S3 + { + get; + } = new("S3", "target identity", + new SessionSetup( + [new SessionTarget("A", CheatEngineProfile.Target64), new SessionTarget("B", CheatEngineProfile.Target64)], "A", + [SessionBundle.Harness], EnableAutoAssembler: true, FileAsProcessCopy: true), + static context => + [ + LuaDriverSteps.MainForm(), + .. LuaDriverSteps.OpenProcess("open-process-a", context.ProcessId("A")), + ScratchAllocation(), + .. LoadHarness(context), + LuaDriverSteps.Call("target-declare", "target_declare", LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.Call("runtime-on-a", "runtime"), + Allocation("allocation-allocate", "allocate", AllocationName, 64), + LuaDriverSteps.Call("value-scan-first", "value_scan", LuaLiteral.String("first"), + LuaLiteral.String(ScratchSymbol), LuaLiteral.Integer(context.FirstMarker)), + Patch("aa-apply", "apply", "benign"), + .. LuaDriverSteps.OpenProcess("open-process-b", context.ProcessId("B")), + LuaDriverSteps.Call("runtime-on-b", "runtime"), + Allocation("allocation-state-on-b", "state", AllocationName, 0), + Allocation("allocation-release-on-b", "release", AllocationName, 0), + LuaDriverSteps.Call("value-scan-state-on-b", "value_scan", LuaLiteral.String("state"), LuaLiteral.String(""), + LuaLiteral.Integer(0)), + LuaDriverSteps.Call("value-scan-release-on-b", "value_scan", LuaLiteral.String("release"), + LuaLiteral.String(""), LuaLiteral.Integer(0)), + Patch("aa-state-on-b", "state", ""), + Patch("aa-release-on-b", "release", ""), + .. LuaDriverSteps.OpenProcess("open-process-a-again", context.ProcessId("A")), + LuaDriverSteps.Call("runtime-back-on-a", "runtime"), + Allocation("allocation-state-back-on-a", "state", AllocationName, 0), + Allocation("allocation-allocate-new", "allocate", SecondAllocationName, 64), + Allocation("allocation-release-new", "release", SecondAllocationName, 0), + LuaDriverSteps.Lua("open-file-as-process", + $"return openFileAsProcess({LuaLiteral.String(context.FileAsProcessPath ?? string.Empty)}, true)"), + LuaDriverSteps.Call("runtime-file-as-process", "runtime"), + Allocation("allocation-unidentified", "allocate-unidentified", AllocationName + "_file", 16), + LuaDriverSteps.Call("value-scan-unidentified", "value_scan", LuaLiteral.String("create-unidentified"), + LuaLiteral.String(""), LuaLiteral.Integer(0)), + LuaDriverSteps.Call("aob-module-unidentified", "aob", LuaLiteral.String(context.ModuleHeaderPattern), + LuaLiteral.String(context.TargetModule), LuaLiteral.Integer(100_000), LuaLiteral.Integer(0)), + LuaDriverSteps.ClearAddressList() + ]); + + /// S4: the x86 target gtutorial-i386. + internal static QualificationSessionPlan S4 + { + get; + } = new("S4", "x86 target on gtutorial-i386", + new SessionSetup([new SessionTarget("A", CheatEngineProfile.Target32)], "A", [SessionBundle.Harness]), + static context => + [ + LuaDriverSteps.MainForm(), + .. LuaDriverSteps.OpenProcess("open-process", context.ProcessId("A")), + ScratchAllocation(), + .. LoadHarness(context), + LuaDriverSteps.Call("target-declare", "target_declare", LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.Call("runtime", "runtime"), + .. RoundTrips("int64-limits", "address-above-4gib"), + LuaDriverSteps.Call("aob-module", "aob", LuaLiteral.String(context.ModuleHeaderPattern), + LuaLiteral.String(context.TargetModule), LuaLiteral.Integer(100_000), LuaLiteral.Integer(0)), + LuaDriverSteps.Call("aob-module-absent", "aob", LuaLiteral.String(context.AbsentPattern), + LuaLiteral.String(context.TargetModule), LuaLiteral.Integer(100_000), LuaLiteral.Integer(0)), + LuaDriverSteps.Call("instructions", "instructions", LuaLiteral.String(ScratchSymbol)), + LuaDriverSteps.ClearAddressList() + ]); + + /// S5a: coexistence in the order Plugin A, the SDK 1.x neighbour, Plugin B. + internal static QualificationSessionPlan S5a + { + get; + } = new("S5a", "coexistence: A, SDK 1.x neighbour, B", CoexistenceSetup(), + static context => Coexistence(context, neighbourFirst: false)); + + /// S5b: coexistence in the order the SDK 1.x neighbour, Plugin A, Plugin B. + internal static QualificationSessionPlan S5b + { + get; + } = new("S5b", "coexistence: SDK 1.x neighbour, A, B", CoexistenceSetup(), + static context => Coexistence(context, neighbourFirst: true)); + + /// S6: the template, instantiated from the packed Templates package and loaded (Q40). + internal static QualificationSessionPlan S6 + { + get; + } = new("S6", "template clean install", + new SessionSetup([], null, [SessionBundle.Template]), + static context => + [ + LuaDriverSteps.MainForm(), + LuaDriverSteps.LoadPlugin("load-template", context.PluginPath(SessionBundle.Template)), + LuaDriverSteps.GlobalReady("template-ready", context.TemplateStatusGlobal), + LuaDriverSteps.CallGlobal("template-status", context.TemplateStatusGlobal), + LuaDriverSteps.ClearAddressList() + ]); + + /// Every session, in run order. + internal static IReadOnlyList All => [S1, S2, S3, S4, S5a, S5b, S6]; + + /// The plan of one session. + internal static QualificationSessionPlan Get(string session) + { + return All.SingleOrDefault(plan => plan.Session == session) ?? + throw new ArgumentException($"No session '{session}'.", nameof(session)); + } + + private static LuaDriverStep ScratchAllocation() + { + string script = $"alloc({ScratchSymbol},4096)\nregistersymbol({ScratchSymbol})"; + return LuaDriverSteps.Lua("scratch-allocation", $"return tostring(autoAssemble({LuaLiteral.String(script)}))"); + } + + private static IEnumerable LoadHarness(SessionContext context) + { + yield return LuaDriverSteps.LoadPlugin("load-plugin", context.PluginPath(SessionBundle.Harness)); + yield return LuaDriverSteps.GlobalReady("harness-ready", LuaDriverScript.HarnessFunctionPrefix + "status"); + } + + /// + /// Reads the whole scratch region through Cheat Engine (not the Client) and folds it into one number, with the + /// opened process id, so Q45 can compare it around the capability probe. + /// + private static LuaDriverStep ScratchDigest(string step) + { + return LuaDriverSteps.Lua(step, $$""" + local bytes = readBytes({{LuaLiteral.String(ScratchSymbol)}}, 4096, true) + if bytes == nil then error("the scratch region is not readable") end + local sum = 0 + for index = 1, #bytes do sum = (sum * 31 + bytes[index]) % 2147483647 end + return getOpenedProcessID() .. ":" .. #bytes .. ":" .. sum + """); + } + + private static IEnumerable RoundTrips(params string[] kinds) + { + foreach (string kind in kinds) + { + yield return LuaDriverSteps.Call("roundtrip-" + kind, "memory_roundtrip", LuaLiteral.String(kind), + LuaLiteral.String(ScratchSymbol)); + } + } + + private static IEnumerable AobSteps(SessionContext context) + { + string header = LuaLiteral.String(context.ModuleHeaderPattern); + string absent = LuaLiteral.String(context.AbsentPattern); + string module = LuaLiteral.String(context.TargetModule); + string none = LuaLiteral.String(string.Empty); + yield return LuaDriverSteps.Call("aob-known", "aob", header, none, LuaLiteral.Integer(100), LuaLiteral.Integer(0)); + yield return LuaDriverSteps.Call("aob-absent", "aob", absent, none, LuaLiteral.Integer(100), LuaLiteral.Integer(0)); + yield return LuaDriverSteps.Call("aob-module", "aob", header, module, LuaLiteral.Integer(100_000), + LuaLiteral.Integer(0)); + yield return LuaDriverSteps.Call("aob-module-absent", "aob", absent, module, LuaLiteral.Integer(100_000), + LuaLiteral.Integer(0)); + yield return LuaDriverSteps.Call("aob-limit", "aob", header, none, LuaLiteral.Integer(1), LuaLiteral.Integer(0)); + yield return LuaDriverSteps.Call("aob-cancel", "aob", header, none, LuaLiteral.Integer(100), LuaLiteral.Integer(1)); + } + + private static LuaDriverStep Allocation(string step, string action, string name, long size) + { + return LuaDriverSteps.Call(step, "allocation", LuaLiteral.String(action), LuaLiteral.String(name), + LuaLiteral.Integer(size)); + } + + private static LuaDriverStep Patch(string step, string action, string variant) + { + return LuaDriverSteps.Call(step, "aa_patch", LuaLiteral.String(action), LuaLiteral.String(variant)); + } + + /// + /// Writes the harness fault switch the next enable reads: Configure for the enable that must fail (Q06), + /// then S2's own ModuleOnDisabling again, so the enables after it keep the fault whose disable Q43 reads. + /// + private static string WriteFaultSwitch(SessionContext context, FaultStage stage) + { + string directory = Path.GetDirectoryName(context.PluginPath(SessionBundle.Harness)) ?? string.Empty; + string path = LuaLiteral.String(Path.Combine(directory, QualificationFaultSwitch.FileName)); + string content = LuaLiteral.String( + $$"""{ "schema": "{{QualificationFaultSwitch.Schema}}", "throwIn": "{{stage}}" }"""); + return $""" + local file = assert(io.open({path}, "wb")) + file:write({content}) + file:close() + return "written" + """; + } + + private static SessionSetup CoexistenceSetup() + { + return new SessionSetup([], null, + [SessionBundle.PluginA, SessionBundle.PluginB, SessionBundle.PluginCollision, SessionBundle.SdkNeighbour]); + } + + private static IReadOnlyList Coexistence(SessionContext context, bool neighbourFirst) + { + LuaDriverStep[] neighbour = + [ + LuaDriverSteps.LoadPlugin("load-neighbour", context.PluginPath(SessionBundle.SdkNeighbour)), + LuaDriverSteps.GlobalReady("neighbour-ready", NeighbourPluginSource.IdentityGlobal) + ]; + LuaDriverStep[] pluginA = + [ + LuaDriverSteps.LoadPlugin("load-plugin-a", context.PluginPath(SessionBundle.PluginA)), + LuaDriverSteps.GlobalReady("a-ready", AIdentity) + ]; + return + [ + LuaDriverSteps.MainForm(), + .. neighbourFirst ? neighbour : pluginA, + .. neighbourFirst ? pluginA : neighbour, + LuaDriverSteps.LoadPlugin("load-plugin-b", context.PluginPath(SessionBundle.PluginB)), + LuaDriverSteps.GlobalReady("b-ready", BIdentity), + LuaDriverSteps.CallGlobal("a-identity", AIdentity), + LuaDriverSteps.CallGlobal("b-identity", BIdentity), + LuaDriverSteps.CallGlobal("neighbour-identity", NeighbourPluginSource.IdentityGlobal), + LuaDriverSteps.CallGlobal("a-ping", APing), + LuaDriverSteps.CallGlobal("b-ping", BPing), + LuaDriverSteps.CallGlobal("a-collision-before", ACollision), + LuaDriverSteps.LoadPlugin("load-collision", context.PluginPath(SessionBundle.PluginCollision)), + LuaDriverSteps.CallGlobal("a-collision-after", ACollision), + LuaDriverSteps.Lua("third-party-replace", $$""" + cheatengine_client_coexistence_q16_third_party = function() return "ThirdParty=Q16" end + {{APing}} = cheatengine_client_coexistence_q16_third_party + return "replaced" + """), + LuaDriverSteps.Toggle("toggle-disable-a", false, PluginADisplayName, AIdentity), + LuaDriverSteps.Lua("third-party-survives", $$""" + return tostring({{APing}} == cheatengine_client_coexistence_q16_third_party and {{AIdentity}} == nil and type({{BIdentity}}) == "function") + """), + LuaDriverSteps.CallGlobal("b-ping-after-a-disabled", BPing), + LuaDriverSteps.Toggle("toggle-disable-b", false, PluginBDisplayName, BIdentity), + LuaDriverSteps.ClearAddressList() + ]; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/TargetLauncher.cs b/tests/CheatEngine.Client.Tests/LiveQualification/TargetLauncher.cs new file mode 100644 index 0000000..a58f644 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/TargetLauncher.cs @@ -0,0 +1,140 @@ +using System.Diagnostics; +using System.Runtime.Versioning; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// A disposable gtutorial target started from the sandbox; disposing it closes, then kills, the process. +[SupportedOSPlatform("windows")] +internal sealed class LaunchedTarget : IDisposable +{ + private readonly Process _process; + + internal LaunchedTarget(Process process, string imageName, string imageSha256, IReadOnlyList modulesAtStart) + { + _process = process; + ProcessId = process.Id; + StartedUtc = process.StartTime.ToUniversalTime(); + ImageName = imageName; + ImageSha256 = imageSha256; + ModulesAtStart = modulesAtStart; + } + + /// The target process id, which the authorization manifest names. + internal int ProcessId + { + get; + } + + /// When the target started, so a reused process id is detectable. + internal DateTime StartedUtc + { + get; + } + + /// The file name of the target image. + internal string ImageName + { + get; + } + + /// The upper-case SHA-256 of the target image, verified before the start. + internal string ImageSha256 + { + get; + } + + /// The lower-case module names loaded right after the start. + internal IReadOnlyList ModulesAtStart + { + get; + } + + /// Whether the target is still running. + internal bool IsRunning => !_process.HasExited; + + /// The lower-case module names loaded now (Q45: compare with ). + internal IReadOnlyList SnapshotModules() + { + return TargetLauncher.ModuleNames(_process); + } + + /// + public void Dispose() + { + HostProcessGuard.Stop(_process); + _process.Dispose(); + } +} + +/// +/// Starts the sandboxed gtutorial targets and records their identity (process id, start time, image SHA-256) and their +/// modules, the Q45 evidence that the Client injected nothing: no speedhack, allochook, luaclient, vehdebug or dbk +/// module may appear in the target. +/// +[SupportedOSPlatform("windows")] +internal static class TargetLauncher +{ + /// Module name prefixes that must never appear in a target (Q45). + internal static readonly string[] ForbiddenModulePrefixes = ["speedhack", "allochook", "luaclient", "vehdebug", "dbk"]; + + private static readonly TimeSpan StartupWait = TimeSpan.FromSeconds(30); + + /// Starts after checking its SHA-256, and waits until it is idle. + internal static LaunchedTarget Start(string executable, string expectedSha256) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executable); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedSha256); + string sha256 = CheatEngineInstallation.Sha256(executable); + if (!string.Equals(sha256, expectedSha256, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"'{Path.GetFileName(executable)}' has SHA-256 {sha256}, expected {expectedSha256}."); + } + + ProcessStartInfo startInfo = new(executable) + { + WorkingDirectory = Path.GetDirectoryName(executable)!, + UseShellExecute = false + }; + CheatEngineEnvironment.Apply(startInfo.Environment, new Dictionary(StringComparer.Ordinal), false); + startInfo.Environment.Remove(CheatEngineEnvironment.IdentifyOnEnableVariable); + Process process = Process.Start(startInfo) ?? throw new InvalidOperationException($"'{executable}' did not start."); + try + { + process.WaitForInputIdle(StartupWait); + return new LaunchedTarget(process, Path.GetFileName(executable), sha256, ModuleNames(process)); + } + catch + { + HostProcessGuard.Stop(process); + process.Dispose(); + throw; + } + } + + /// The forbidden modules among , lower-case and ordinally sorted. + internal static IReadOnlyList FindForbiddenModules(IEnumerable moduleNames) + { + ArgumentNullException.ThrowIfNull(moduleNames); + return moduleNames + .Select(static name => name.ToLowerInvariant()) + .Where(static name => ForbiddenModulePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.Ordinal))) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + internal static IReadOnlyList ModuleNames(Process process) + { + List names = []; + foreach (ProcessModule module in process.Modules) + { + using (module) + { + names.Add(module.ModuleName.ToLowerInvariant()); + } + } + + names.Sort(StringComparer.Ordinal); + return names; + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/TranscriptParser.cs b/tests/CheatEngine.Client.Tests/LiveQualification/TranscriptParser.cs new file mode 100644 index 0000000..bd45752 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/TranscriptParser.cs @@ -0,0 +1,305 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// How a driver step ended. +internal enum TranscriptStatus +{ + /// The step returned; is its result. + Ok, + + /// The step raised a Lua error or never produced a result; the value is the error. + Error, + + /// The driver did not attempt the step; the value is the operator prompt. + NotExecuted +} + +/// One decoded transcript record. +/// The 1-based line the record starts on. +/// The step name. +/// How the step ended. +/// The decoded value (UTF-8). +internal sealed record TranscriptRecord(int Line, string Step, TranscriptStatus Status, string Value); + +/// A parsed transcript. +/// The records, in order. +/// Whether the driver wrote DONE. +/// Every line that is not a well-formed record. +internal sealed record Transcript(IReadOnlyList Records, bool Completed, IReadOnlyList Problems) +{ + /// The last record of , or . + internal TranscriptRecord? Find(string step) + { + return Records.LastOrDefault(record => string.Equals(record.Step, step, StringComparison.Ordinal)); + } +} + +/// +/// Decodes the driver transcript: R<TAB>step<TAB>ok|error|notexecuted<TAB>"value" records, +/// whose value is Lua 5.3 %q output (a backslash before a quote, a backslash or a line feed, \r, +/// \ddd decimal bytes), and a final DONE line. The value is decoded byte by byte, then as UTF-8. +/// +[SupportedOSPlatform("windows")] +internal static class TranscriptParser +{ + /// Parses the transcript file; a missing file is an empty, incomplete transcript. + internal static Transcript ParseFile(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + return File.Exists(path) + ? Parse(File.ReadAllBytes(path)) + : new Transcript([], false, ["The driver wrote no transcript."]); + } + + /// Parses transcript bytes. + internal static Transcript Parse(ReadOnlySpan content) + { + List records = []; + List problems = []; + bool completed = false; + int position = 0; + int line = 1; + while (position < content.Length) + { + int start = line; + ReadOnlySpan rest = content[position..]; + if (completed) + { + problems.Add(Problem(start, "content after DONE")); + break; + } + + if (TryMatchLine(rest, "DONE"u8, out int doneLength)) + { + completed = true; + position += doneLength; + line++; + continue; + } + + if (TryParseRecord(rest, start, out TranscriptRecord? record, out int consumed, out int lines, out string? error)) + { + records.Add(record); + position += consumed; + line += lines; + continue; + } + + problems.Add(Problem(start, error)); + int next = rest.IndexOf((byte) '\n'); + position = next < 0 ? content.Length : position + next + 1; + line++; + } + + return new Transcript(records, completed, problems); + } + + private static bool TryMatchLine(ReadOnlySpan rest, ReadOnlySpan text, out int length) + { + length = 0; + if (!rest.StartsWith(text)) + { + return false; + } + + int end = EndOfLine(rest, text.Length); + if (end < 0) + { + return false; + } + + length = end; + return true; + } + + /// The length through the line ending at (LF, CRLF or the end), or -1. + private static int EndOfLine(ReadOnlySpan rest, int index) + { + if (index == rest.Length) + { + return index; + } + + if (rest[index] == (byte) '\n') + { + return index + 1; + } + + return rest[index] == (byte) '\r' && index + 1 < rest.Length && rest[index + 1] == (byte) '\n' ? index + 2 : -1; + } + + private static bool TryParseRecord(ReadOnlySpan rest, int line, [NotNullWhen(true)] out TranscriptRecord? record, + out int consumed, out int lines, [NotNullWhen(false)] out string? error) + { + record = null; + consumed = 0; + lines = 1; + error = null; + if (!rest.StartsWith("R\t"u8)) + { + error = "not a record"; + return false; + } + + int index = 2; + if (!TryReadField(rest, ref index, out string step) || step.Length == 0 || + !TryReadField(rest, ref index, out string statusText)) + { + error = "a record needs a step and a status separated by tabs"; + return false; + } + + TranscriptStatus? status = statusText switch + { + "ok" => TranscriptStatus.Ok, + "error" => TranscriptStatus.Error, + "notexecuted" => TranscriptStatus.NotExecuted, + _ => null + }; + if (status is null) + { + error = $"unknown status '{statusText}'"; + return false; + } + + if (!TryDecodeQuoted(rest, ref index, ref lines, out string? value, out error)) + { + return false; + } + + int end = EndOfLine(rest, index); + if (end < 0) + { + error = "the quoted value is followed by more text"; + return false; + } + + consumed = end; + record = new TranscriptRecord(line, step, status.Value, value); + return true; + } + + private static bool TryReadField(ReadOnlySpan rest, ref int index, out string field) + { + int tab = rest[index..].IndexOfAny((byte) '\t', (byte) '\n'); + if (tab < 0 || rest[index + tab] != (byte) '\t') + { + field = string.Empty; + return false; + } + + field = Encoding.UTF8.GetString(rest.Slice(index, tab)); + index += tab + 1; + return true; + } + + private static bool TryDecodeQuoted(ReadOnlySpan rest, ref int index, ref int lines, + [NotNullWhen(true)] out string? value, + [NotNullWhen(false)] out string? error) + { + value = null; + error = null; + if (index >= rest.Length || rest[index] != (byte) '"') + { + error = "the value is not a quoted string"; + return false; + } + + List bytes = []; + index++; + while (index < rest.Length) + { + byte current = rest[index++]; + if (current == (byte) '"') + { + value = Encoding.UTF8.GetString([.. bytes]); + return true; + } + + if (current == (byte) '\n') + { + error = "the quoted value contains an unescaped line feed"; + return false; + } + + if (current != (byte) '\\') + { + bytes.Add(current); + continue; + } + + if (index >= rest.Length) + { + break; + } + + byte escape = rest[index++]; + switch (escape) + { + case (byte) '\n': + bytes.Add((byte) '\n'); + lines++; + break; + case (byte) '\r' when index < rest.Length && rest[index] == (byte) '\n': + index++; + bytes.Add((byte) '\n'); + lines++; + break; + case (byte) '"' or (byte) '\\' or (byte) '\'': + bytes.Add(escape); + break; + case (byte) 'n': + bytes.Add((byte) '\n'); + break; + case (byte) 'r': + bytes.Add((byte) '\r'); + break; + case (byte) 't': + bytes.Add((byte) '\t'); + break; + case (byte) 'a': + bytes.Add(7); + break; + case (byte) 'b': + bytes.Add(8); + break; + case (byte) 'f': + bytes.Add(12); + break; + case (byte) 'v': + bytes.Add(11); + break; + case >= (byte) '0' and <= (byte) '9': + int decimalValue = escape - '0'; + for (int digit = 0; digit < 2 && index < rest.Length && char.IsAsciiDigit((char) rest[index]); digit++) + { + decimalValue = (decimalValue * 10) + (rest[index++] - '0'); + } + + if (decimalValue > byte.MaxValue) + { + error = string.Create(CultureInfo.InvariantCulture, $"the decimal escape \\{decimalValue} exceeds 255"); + return false; + } + + bytes.Add((byte) decimalValue); + break; + default: + error = $"unsupported escape '\\{(char) escape}'"; + return false; + } + } + + error = "the quoted value is not terminated"; + return false; + } + + private static string Problem(int line, string? detail) + { + return string.Create(CultureInfo.InvariantCulture, $"line {line}: {detail}"); + } +} diff --git a/tests/CheatEngine.Client.Tests/LiveQualification/TranscriptParserTests.cs b/tests/CheatEngine.Client.Tests/LiveQualification/TranscriptParserTests.cs new file mode 100644 index 0000000..5aff17a --- /dev/null +++ b/tests/CheatEngine.Client.Tests/LiveQualification/TranscriptParserTests.cs @@ -0,0 +1,116 @@ +using System.Runtime.Versioning; +using System.Text; + +namespace CheatEngine.Client.Tests.LiveQualification; + +/// +/// The transcript decoder, on the exact bytes Lua 5.3 writes: string.format("%q") escapes a quote, a backslash +/// and a line feed with a backslash, writes \r and decimal \ddd escapes, and passes UTF-8 through. +/// +[SupportedOSPlatform("windows")] +public sealed class TranscriptParserTests +{ + [Fact] + public void RecordsAndTheDoneLineAreDecoded() + { + Transcript transcript = Parse( + "R\tmain-form\tok\t\"ready\"\n" + + "R\tload-plugin\terror\t\"loadPlugin failed\"\n" + + "R\ttoggle-disable\tnotexecuted\t\"Operator: untick it\"\n" + + "DONE\n"); + + Assert.True(transcript.Completed); + Assert.Empty(transcript.Problems); + Assert.Equal( + [ + new TranscriptRecord(1, "main-form", TranscriptStatus.Ok, "ready"), + new TranscriptRecord(2, "load-plugin", TranscriptStatus.Error, "loadPlugin failed"), + new TranscriptRecord(3, "toggle-disable", TranscriptStatus.NotExecuted, "Operator: untick it") + ], transcript.Records); + } + + [Fact] + public void LuaQuotedEscapesAreDecoded() + { + // %q of: a"b\cde1 é, then a record on the next physical line. + Transcript transcript = Parse("R\tstatus\tok\t\"a\\\"b\\\\c\\\nd\\re\\0001 \u00e9\"\nR\tnext\tok\t\"\\195\\169\\9\"\nDONE"); + + Assert.True(transcript.Completed); + Assert.Empty(transcript.Problems); + Assert.Equal("a\"b\\c\nd\re\u00001 \u00e9", transcript.Records[0].Value); + Assert.Equal(new TranscriptRecord(3, "next", TranscriptStatus.Ok, "\u00e9\t"), transcript.Records[1]); + } + + [Fact] + public void JsonObservationsSurviveTheRoundTrip() + { + const string observation = """{"ok":true,"message":"line one\nline two","path":"/x"}"""; + + Transcript transcript = Parse("R\tstatus\tok\t\"" + LuaQuote(observation) + "\"\r\nDONE\r\n"); + + Assert.Equal(observation, Assert.Single(transcript.Records).Value); + Assert.True(transcript.Completed); + } + + [Theory] + [InlineData("R\tstep\tmaybe\t\"x\"\n", "unknown status 'maybe'")] + [InlineData("R\tstep\tok\n", "a record needs a step and a status separated by tabs")] + [InlineData("R\t\tok\t\"x\"\n", "a record needs a step and a status separated by tabs")] + [InlineData("R\tstep\tok\tx\n", "the value is not a quoted string")] + [InlineData("R\tstep\tok\t\"x\n\"\n", "the quoted value contains an unescaped line feed")] + [InlineData("R\tstep\tok\t\"x\" tail\n", "the quoted value is followed by more text")] + [InlineData("R\tstep\tok\t\"\\999\"\n", "the decimal escape \\999 exceeds 255")] + [InlineData("R\tstep\tok\t\"\\q\"\n", "unsupported escape '\\q'")] + [InlineData("hello\n", "not a record")] + public void MalformedLinesAreReportedNotDropped(string text, string problem) + { + Transcript transcript = Parse(text + "R\tafter\tok\t\"kept\"\n"); + + Assert.Equal("line 1: " + problem, transcript.Problems[0]); + Assert.Equal("after", transcript.Records[^1].Step); + Assert.False(transcript.Completed); + } + + [Fact] + public void AnUnterminatedValueIsAProblem() + { + // The value ends with an escaped quote, so the file ends inside it. + Transcript transcript = Parse("R\tstep\tok\t\"x\\\""); + + Assert.Equal(["line 1: the quoted value is not terminated"], transcript.Problems); + Assert.Empty(transcript.Records); + } + + [Fact] + public void ContentAfterDoneIsAProblem() + { + Transcript transcript = Parse("DONE\nR\tlate\tok\t\"x\"\n"); + + Assert.True(transcript.Completed); + Assert.Equal(["line 2: content after DONE"], transcript.Problems); + Assert.Empty(transcript.Records); + } + + [Fact] + public void AMissingTranscriptIsIncompleteAndFindReturnsTheLastRecord() + { + Transcript missing = TranscriptParser.ParseFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "transcript.txt")); + Transcript repeated = Parse("R\tstep\terror\t\"first\"\nR\tstep\tok\t\"second\"\n"); + + Assert.False(missing.Completed); + Assert.Equal(["The driver wrote no transcript."], missing.Problems); + Assert.Equal("second", repeated.Find("step")?.Value); + Assert.Null(repeated.Find("other")); + } + + /// What Lua 5.3 %q writes between the quotes for a string without control characters. + private static string LuaQuote(string value) + { + return value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + } + + private static Transcript Parse(string text) + { + return TranscriptParser.Parse(Encoding.UTF8.GetBytes(text)); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/BuildGuardTests.cs b/tests/CheatEngine.Client.Tests/Packaging/BuildGuardTests.cs new file mode 100644 index 0000000..4a85b30 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/BuildGuardTests.cs @@ -0,0 +1,124 @@ +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.Packaging; + +/// +/// Runs the repository's MSBuild guard targets against real projects with overridden global properties. Only the guard +/// target is evaluated and run: nothing is restored, built or packed, so each case takes a few seconds. +/// +public sealed class BuildGuardTests +{ + private const string SdkFacingLibrary = "libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj"; + private const string SdkPinGuard = "CheatEngineClientValidateSdkPin"; + private const string SdkPackGuard = "CheatEngineClientRefuseUnsupportedSdkPack"; + private const string GeneratorProject = + "source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngine.Client.SourceGenerators.Lua.csproj"; + private const string RoslynPinGuard = "CheatEngineClientCheckRoslynPin"; + private const string PackableLibrary = "libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj"; + private const string LockstepGuard = "CheatEngineClientValidateLockstepVersion"; + private const string SbomGuard = "CheatEngineClientRequireSbom"; + private const string ShippingSettingsGuard = "CheatEngineClientValidateShippingPackageSettings"; + + [Fact] + public async Task CommittedPinPassesTheSdkGuardAsync() + { + DotNetProcessResult result = await RunGuardAsync(SdkFacingLibrary, SdkPinGuard); + + Assert.True(result.ExitCode == 0, result.ToString()); + Assert.DoesNotContain("CHEATENGINECLIENT", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task NextMajorPinFailsWithCHEATENGINECLIENT9016Async() + { + int nextMajor = SdkPin.Major + 1; + string[] nextMajorPin = [$"-p:CheatEngineSdkVersion={nextMajor}.0.0", $"-p:CheatEngineSdkUpperBound={nextMajor + 1}.0.0"]; + + DotNetProcessResult result = await RunGuardAsync(SdkFacingLibrary, SdkPinGuard, nextMajorPin); + DotNetProcessResult pack = await RunGuardAsync(SdkFacingLibrary, SdkPackGuard, nextMajorPin); + + Assert.True(result.ExitCode != 0, result.ToString()); + Assert.Contains("error CHEATENGINECLIENT9016", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains($"'{nextMajor}.0.0' has major version {nextMajor}", result.StandardOutput, StringComparison.Ordinal); + Assert.True(pack.ExitCode != 0, pack.ToString()); + Assert.Contains("error CHEATENGINECLIENT9016", pack.StandardOutput, StringComparison.Ordinal); + Assert.Contains("cannot be packed", pack.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task PrereleaseSdkPinFailsWithCHEATENGINECLIENT9016Async() + { + string prerelease = $"{SdkPin.Major}.1.0-beta.1"; + + DotNetProcessResult result = await RunGuardAsync(SdkFacingLibrary, SdkPinGuard, + $"-p:CheatEngineSdkVersion={prerelease}"); + + Assert.True(result.ExitCode != 0, result.ToString()); + Assert.Contains("error CHEATENGINECLIENT9016", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains($"'{prerelease}' is a prerelease", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task RoslynPinDriftFailsWithCHEATENGINECLIENT9020Async() + { + // A -p: switch cannot change a PackageVersion item, so the drift is simulated from the other side: the floor. + DotNetProcessResult committed = await RunGuardAsync(GeneratorProject, RoslynPinGuard); + DotNetProcessResult drifted = await RunGuardAsync(GeneratorProject, RoslynPinGuard, + "-p:CheatEngineClientRoslynComponentFloor=5.8.0"); + + Assert.True(committed.ExitCode == 0, committed.ToString()); + Assert.True(drifted.ExitCode != 0, drifted.ToString()); + Assert.Contains("error CHEATENGINECLIENT9020", drifted.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task LockstepGuardAcceptsMinVerAndRefusesEveryOtherVersionSourceAsync() + { + string minVerThenGuard = $"MinVer;{LockstepGuard}"; + + DotNetProcessResult committed = await RunGuardAsync(PackableLibrary, minVerThenGuard); + DotNetProcessResult skipped = await RunGuardAsync(PackableLibrary, minVerThenGuard, "-p:MinVerSkip=true"); + DotNetProcessResult overridden = await RunGuardAsync(PackableLibrary, minVerThenGuard, "-p:Version=9.9.9"); + + Assert.True(committed.ExitCode == 0, committed.ToString()); + Assert.True(skipped.ExitCode != 0, skipped.ToString()); + Assert.Contains("error CHEATENGINECLIENT9019", skipped.StandardOutput, StringComparison.Ordinal); + Assert.True(overridden.ExitCode != 0, overridden.ToString()); + Assert.Contains("error CHEATENGINECLIENT9019", overridden.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task SbomGuardRefusesAPackWithoutTheSbomAsync() + { + DotNetProcessResult committed = await RunGuardAsync(PackableLibrary, SbomGuard); + DotNetProcessResult disabled = await RunGuardAsync(PackableLibrary, SbomGuard, "-p:GenerateSBOM=false"); + + Assert.True(committed.ExitCode == 0, committed.ToString()); + Assert.True(disabled.ExitCode != 0, disabled.ToString()); + Assert.Contains("error CHEATENGINECLIENT9021", disabled.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task ShippingProjectWithoutTrimReferenceVerificationFailsWithCHEATENGINECLIENT9008Async() + { + DotNetProcessResult committed = await RunGuardAsync(PackableLibrary, ShippingSettingsGuard); + DotNetProcessResult disabled = await RunGuardAsync(PackableLibrary, ShippingSettingsGuard, + "-p:VerifyReferenceTrimCompatibility=false"); + + Assert.True(committed.ExitCode == 0, committed.ToString()); + Assert.DoesNotContain("CHEATENGINECLIENT", committed.StandardOutput, StringComparison.Ordinal); + Assert.True(disabled.ExitCode != 0, disabled.ToString()); + Assert.Contains("error CHEATENGINECLIENT9008", disabled.StandardOutput, StringComparison.Ordinal); + } + + private static Task RunGuardAsync(string project, string target, params string[] properties) + { + List arguments = + [ + "msbuild", RepositoryLayout.Combine(project), $"-t:{target}", "-nologo", "-nodeReuse:false", + "-verbosity:minimal" + ]; + arguments.AddRange(properties); + return DotNetProcess.RunAsync(RepositoryLayout.Root, [.. arguments]); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/ConsumerDiagnosticsTests.cs b/tests/CheatEngine.Client.Tests/Packaging/ConsumerDiagnosticsTests.cs new file mode 100644 index 0000000..39621b3 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/ConsumerDiagnosticsTests.cs @@ -0,0 +1,128 @@ +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.Packaging; + +/// +/// A plugin project that consumes the packed packages and breaks one rule of the Hosting plugin profile fails its +/// build with that rule's CECLIENT diagnostic and no other (the "Build diagnostics" table of the Hosting README). +/// Each case restores and builds its own consumer against the fixture's isolated package cache; the deployment +/// cases also prove that a refused deployment writes nothing. CECLIENT001 and CECLIENT017 are covered +/// by . +/// +[Collection(PackageConsumptionSmokeSerialGroup.Name)] +[Trait("Category", "PackageConsumption")] +public sealed partial class ConsumerDiagnosticsTests(PackagedClientFeedFixture fixture) +{ + private const int RegexTimeoutMilliseconds = 1000; + + /// A plugin that needs only the Hosting package, so every case compiles once its check passes. + private const string PluginSource = + """ + using CheatEngine.Client.Hosting; + using CheatEngine.SDK.Annotations.Plugin; + + [CheatEnginePlugin("Consumer diagnostics plugin")] + public sealed class Plugin : CheatEngineClientPlugin + { + protected override void Configure(CheatEnginePluginBuilder builder) + { + } + } + """; + + /// Drops the SDK's Lua bridge from the files copied to the output, like a broken build asset. + private const string DropLuaBridgeTarget = + """ + + + + + + """; + + [Theory] + [InlineData("CECLIENT002", "", false)] + [InlineData("CECLIENT005", "net10.0-windows", false)] + [InlineData("CECLIENT006", "13.0", false)] + [InlineData("CECLIENT007", "x86", false)] + [InlineData("CECLIENT008", "false", false)] + [InlineData("CECLIENT011", "false", true)] + [InlineData("CECLIENT012", "false", true)] + [InlineData("CECLIENT013", "false", true)] + [InlineData("CECLIENT015", "", true)] + public async Task APluginThatBreaksOneProfileRuleFailsWithItsDiagnosticAsync(string code, string property, + bool deploys) + { + fixture.RequirePackages(); + string directory = fixture.CreateDirectory($"consumer-diagnostic-{code}"); + string deployment = Path.Combine(fixture.Root, $"consumer-diagnostic-{code}-deployment"); + string project = Path.Combine(directory, "Diagnostics.Plugin.csproj"); + // CECLIENT002: the plugin references CheatEngine.Client.Hosting, which brings the profile, but not the Client. + string clientPackage = code == "CECLIENT002" + ? PackagedClientFeedFixture.HostingPackageId + : PackagedClientFeedFixture.ClientPackageId; + string extra = code == "CECLIENT015" ? DropLuaBridgeTarget : string.Empty; + await File.WriteAllTextAsync(project, CreateProject(clientPackage, property, extra), new UTF8Encoding(false), + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(directory, "Plugin.cs"), PluginSource, new UTF8Encoding(false), + TestContext.Current.CancellationToken); + + DotNetProcessResult restore = await fixture.RunAsync(directory, "restore", project, "--configfile", + fixture.NuGetConfiguration, "--packages", fixture.PackageCache); + List arguments = + ["build", project, "--configuration", "Release", "--no-restore", "-p:UseSharedCompilation=false"]; + if (deploys) + { + arguments.Add($"-p:CheatEnginePluginOutputPath={deployment}"); + } + + DotNetProcessResult build = await fixture.RunAsync(directory, arguments.ToArray()); + string[] reported = + [ + .. ReportedError().Matches(build.StandardOutput).Select(static match => match.Groups["code"].Value) + .Distinct(StringComparer.Ordinal) + ]; + + Assert.True(restore.ExitCode == 0, restore.ToString()); + Assert.True(build.ExitCode != 0, build.ToString()); + Assert.True(reported.Length == 1 && reported[0] == code, + $"Expected {code} alone, got [{string.Join(", ", reported)}].{Environment.NewLine}{build}"); + // A deployment check runs before anything is staged or written. + Assert.False(Directory.Exists(deployment) && Directory.EnumerateFileSystemEntries(deployment).Any(), + $"{code} left files in the deployment folder '{deployment}'."); + PackagedClientFeedFixture.Evidence(nameof(APluginThatBreaksOneProfileRuleFailsWithItsDiagnosticAsync), + $"code={code} package={clientPackage} property={property} deployment={deploys} build=error {code}"); + } + + private string CreateProject(string clientPackage, string property, string extra) + { + const string sdkPackage = PackagedClientFeedFixture.SdkPackageId; + return $""" + + + net10.0 + 14.0 + enable + enable + x64 + true + false + {property} + + + + + + {extra} + + """; + } + + /// An MSBuild error line of a CECLIENT build diagnostic, whatever its message. + [GeneratedRegex(@"\berror (?CECLIENT\d{3})\s*:", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ReportedError(); +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/PackageArchive.cs b/tests/CheatEngine.Client.Tests/Packaging/PackageArchive.cs new file mode 100644 index 0000000..623cd0b --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/PackageArchive.cs @@ -0,0 +1,171 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Xml.Linq; + +namespace CheatEngine.Client.Tests.Packaging; + +/// One dependency declared by a nuspec. +internal sealed record PackageDependency(string Id, string Version, string? Exclude); + +/// A .nupkg or .snupkg read once into memory: file hash, nuspec metadata and every entry. +internal sealed class PackageArchive +{ + private readonly Dictionary _entries; + + private PackageArchive(string path, Dictionary entries, XDocument nuspec) + { + Path = path; + FileName = System.IO.Path.GetFileName(path); + IsSymbolPackage = FileName.EndsWith(".snupkg", StringComparison.OrdinalIgnoreCase); + _entries = entries; + Nuspec = nuspec; + XNamespace ns = nuspec.Root!.Name.Namespace; + Metadata = nuspec.Root.Element(ns + "metadata") + ?? throw new InvalidOperationException($"Package '{path}' does not declare metadata."); + Id = Metadata.Element(ns + "id")?.Value + ?? throw new InvalidOperationException($"Package '{path}' does not declare an id."); + Version = Metadata.Element(ns + "version")?.Value + ?? throw new InvalidOperationException($"Package '{path}' does not declare a version."); + + List dependencies = []; + foreach (XElement dependency in Metadata.Descendants(ns + "dependency")) + { + dependencies.Add(new PackageDependency((string) dependency.Attribute("id")!, (string) dependency.Attribute("version")!, + (string?) dependency.Attribute("exclude"))); + } + + Dependencies = dependencies; + Sha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + } + + /// The absolute path of the archive. + internal string Path + { + get; + } + + /// The archive file name. + internal string FileName + { + get; + } + + /// Whether this is a symbol package. + internal bool IsSymbolPackage + { + get; + } + + /// The nuspec id. + internal string Id + { + get; + } + + /// The nuspec version. + internal string Version + { + get; + } + + /// The lowercase SHA-256 of the archive file. + internal string Sha256 + { + get; + } + + /// The nuspec document. + internal XDocument Nuspec + { + get; + } + + /// The nuspec metadata element. + internal XElement Metadata + { + get; + } + + /// Every dependency of every group. + internal IReadOnlyList Dependencies + { + get; + } + + /// The entry names, with forward slashes. + internal IEnumerable EntryNames => _entries.Keys; + + /// Reads every .nupkg and .snupkg of a directory. + internal static IReadOnlyList ReadDirectory(string directory) + { + List archives = []; + foreach (string file in Directory.EnumerateFiles(directory)) + { + if (file.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".snupkg", StringComparison.OrdinalIgnoreCase)) + { + archives.Add(Read(file)); + } + } + + archives.Sort(static (left, right) => string.CompareOrdinal(left.FileName, right.FileName)); + return archives; + } + + /// Reads one archive. + internal static PackageArchive Read(string path) + { + Dictionary entries = new(StringComparer.Ordinal); + using (ZipArchive archive = ZipFile.OpenRead(path)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (entry.FullName.EndsWith('/')) + { + continue; + } + + using Stream stream = entry.Open(); + using MemoryStream copy = new(); + stream.CopyTo(copy); + entries[entry.FullName.Replace('\\', '/')] = copy.ToArray(); + } + } + + KeyValuePair nuspec = Assert.Single(entries, + static entry => !entry.Key.Contains('/', StringComparison.Ordinal) && entry.Key.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + using MemoryStream nuspecStream = new(nuspec.Value); + return new PackageArchive(path, entries, XDocument.Load(nuspecStream)); + } + + /// Whether the archive has this entry. + internal bool Contains(string entryName) + { + return _entries.ContainsKey(entryName); + } + + /// The bytes of an entry; fails the test when it is missing. + internal byte[] Entry(string entryName) + { + Assert.True(_entries.TryGetValue(entryName, out byte[]? bytes), $"{FileName} has no entry '{entryName}'."); + return bytes!; + } + + /// The text of an entry (UTF-8). + internal string EntryText(string entryName) + { + using StreamReader reader = new(new MemoryStream(Entry(entryName)), detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + /// The value of a nuspec metadata element, or . + internal string? MetadataValue(string name) + { + return Metadata.Element(Metadata.Name.Namespace + name)?.Value; + } + + /// A nuspec metadata element, or . + internal XElement? MetadataElement(string name) + { + return Metadata.Element(Metadata.Name.Namespace + name); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/PackageConsumptionSmokeTests.cs b/tests/CheatEngine.Client.Tests/Packaging/PackageConsumptionSmokeTests.cs index 4ec58ee..d6ff650 100644 --- a/tests/CheatEngine.Client.Tests/Packaging/PackageConsumptionSmokeTests.cs +++ b/tests/CheatEngine.Client.Tests/Packaging/PackageConsumptionSmokeTests.cs @@ -1,316 +1,615 @@ -using System.IO.Compression; -using System.Security; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; using System.Xml.Linq; using CheatEngine.Client.Tests.Infrastructure; namespace CheatEngine.Client.Tests.Packaging; -[CollectionDefinition(Name, DisableParallelization = true)] -public sealed class PackageConsumptionSmokeSerialGroup -{ - public const string Name = "Package consumption smoke"; -} - -/// Exercises the packages that plugin authors consume, outside the repository's project graph. +/// +/// Proves the packages plugin authors consume, not the workspace (audit Q40 at C0-C2, A21-10, A04-09, A04-10): the exact +/// package directory of the CI Release leg, consumed from a clean folder with an isolated NuGet cache and package source +/// mapping, restored, built, deployed, and instantiated as a template. These are fixture-level (C2) results; a Cheat +/// Engine host run of Q40 is a separate qualification. +/// [Collection(PackageConsumptionSmokeSerialGroup.Name)] -public sealed class PackageConsumptionSmokeTests +[Trait("Category", "PackageConsumption")] +[Trait("Qualification", "Q40")] +public sealed partial class PackageConsumptionSmokeTests(PackagedClientFeedFixture fixture) { - private const string PackageSourceEnvironmentVariable = "CHEATENGINE_CLIENT_PACKAGE_SOURCE"; - private const string ClientPackageId = "CheatEngine.Client"; - private const string HostingPackageId = "CheatEngine.Client.Hosting"; - private const string TemplatePackageId = "CheatEngine.Client.Templates"; - - private const string ConsumerSource = """ - using CheatEngine.Client; - using CheatEngine.Client.Hosting; - using CheatEngine.Client.Memory; - using CheatEngine.Client.Scanning; - using CheatEngine.SDK.Annotations.Plugin; - using CheatEngine.SDK.Engine.Values; - - [CheatEnginePlugin("Package smoke plugin")] - public sealed class Plugin : CheatEngineClientPlugin - { - protected override void Configure(CheatEnginePluginBuilder builder) - { - } - - protected override void OnClientEnabled(ICheatEngineClient client) - { - _ = client.Memory.At(default(Address)); - _ = client.Patterns.Aob("00").FirstOrNone(); - } - } - """; + private const string RepositoryUrl = "https://github.com/CheatEngineNet/CheatEngine.Client"; + private const string SbomEntry = "_manifest/spdx_2.2/manifest.spdx.json"; + private const string TemplateProjectEntry = "content/CheatEngine.Plugin/CheatEngine.Plugin.csproj"; + private const string GeneratorEntry = "analyzers/dotnet/cs/CheatEngine.Client.SourceGenerators.Lua.dll"; + private const int RegexTimeoutMilliseconds = 1000; + private static readonly Guid SourceLinkKind = new("CC110556-A091-4D38-9FEC-25AB9A351A6A"); + + /// The observed exclude attribute of each direct CheatEngine.SDK dependency, frozen. + private static readonly Dictionary SdkDependencyExclude = new(StringComparer.Ordinal) + { + ["CheatEngine.Client.Abstractions"] = "Build,Native,Analyzers,BuildTransitive", + ["CheatEngine.Client.Core"] = "Build,Analyzers", + ["CheatEngine.Client.Hosting"] = "Build,Native,Analyzers,BuildTransitive" + }; + + /// The Client packages each package depends on, in ordinal order, frozen: the edges of the delivery graph. + private static readonly Dictionary InterClientDependencies = new(StringComparer.Ordinal) + { + ["CheatEngine.Client"] = ["CheatEngine.Client.Fluent", "CheatEngine.Client.Hosting"], + ["CheatEngine.Client.Abstractions"] = [], + ["CheatEngine.Client.Core"] = ["CheatEngine.Client.Abstractions"], + ["CheatEngine.Client.Extensions.DependencyInjection"] = ["CheatEngine.Client.Abstractions", "CheatEngine.Client.Core"], + ["CheatEngine.Client.Fluent"] = ["CheatEngine.Client.Abstractions"], + ["CheatEngine.Client.Hosting"] = ["CheatEngine.Client.Extensions.DependencyInjection"], + ["CheatEngine.Client.Templates"] = [] + }; [Fact] - public async Task PackagedClientAndTemplateCanBeInstalledInstantiatedAndBuiltInIsolatedDirectories() + public void SevenPackagesAndFiveSymbolPackagesAreProduced() { - using TemporaryDirectory temporary = new("PackageConsumptionSmoke"); - string packageSource = await ResolvePackageSourceAsync(temporary); - - PackageArchive clientPackage = FindPackage(packageSource, ClientPackageId); - PackageArchive hostingPackage = FindPackage(packageSource, HostingPackageId); - PackageArchive templatePackage = FindPackage(packageSource, TemplatePackageId); - AssertArchiveContains(hostingPackage.Path, - "analyzers/dotnet/cs/CheatEngine.Client.SourceGenerators.Lua.dll", - "buildTransitive/CheatEngine.Client.Hosting.props", - "buildTransitive/CheatEngine.Client.Hosting.targets"); - AssertArchiveContains(templatePackage.Path, - "content/CheatEngine.Plugin/.template.config/template.json", - "content/CheatEngine.Plugin/CheatEngine.Plugin.csproj", - "content/CheatEngine.Plugin/Modules/PluginClientModule.cs", - "content/CheatEngine.Plugin/Modules/PluginLuaModule.cs"); - - string nuGetConfiguration = WriteNuGetConfiguration(temporary, packageSource); - await BuildIsolatedPackageConsumerAsync(temporary, nuGetConfiguration, clientPackage.Version); - await InstallInstantiateAndBuildTemplateAsync(temporary, nuGetConfiguration, templatePackage.Path); + fixture.RequirePackages(); + string[] packages = fixture.Archives.Where(static archive => !archive.IsSymbolPackage).Select(static archive => archive.Id).Order(StringComparer.Ordinal).ToArray(); + string[] symbols = fixture.Archives.Where(static archive => archive.IsSymbolPackage).Select(static archive => archive.Id).Order(StringComparer.Ordinal).ToArray(); + string[] others = Directory.GetFiles(fixture.PackageSource) + .Where(static file => !file.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase) && !file.EndsWith(".snupkg", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + Assert.Equal(PackagedClientFeedFixture.PackageIds.Order(StringComparer.Ordinal), packages); + Assert.Equal(PackagedClientFeedFixture.SymbolPackageIds.Order(StringComparer.Ordinal), symbols); + Assert.Empty(others); + foreach (PackageArchive symbol in fixture.Archives.Where(static archive => archive.IsSymbolPackage)) + { + Assert.Contains(symbol.EntryNames, static entry => entry.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)); + } + + foreach (PackageArchive archive in fixture.Archives) + { + PackagedClientFeedFixture.Evidence(nameof(SevenPackagesAndFiveSymbolPackagesAreProduced), $"file={archive.FileName} sha256={archive.Sha256}"); + } } [Fact] - public async Task PackagedClientPluginWithoutDirectSdkReferenceReportsCECLIENT001() + public void EveryClientPackageSharesOneVersion() { - using TemporaryDirectory temporary = new("PackageConsumptionSmoke"); - string packageSource = await ResolvePackageSourceAsync(temporary); - - PackageArchive clientPackage = FindPackage(packageSource, ClientPackageId); - string nuGetConfiguration = WriteNuGetConfiguration(temporary, packageSource); - string consumerDirectory = temporary.CreateDirectory("missing-sdk-package-consumer"); - string projectPath = Path.Combine(consumerDirectory, "MissingSdk.Plugin.csproj"); - await File.WriteAllTextAsync(projectPath, - CreateConsumerProject(clientPackage.Version, false), new UTF8Encoding(false), - TestContext.Current.CancellationToken); - - await AssertDotNetSuccessAsync(consumerDirectory, "restore", projectPath, "--configfile", nuGetConfiguration); - - DotNetProcessResult buildResult = await DotNetProcess.RunAsync(consumerDirectory, - "build", projectPath, "--configuration", "Release", "--no-restore"); - Assert.True(buildResult.ExitCode != 0, buildResult.ToString()); - Assert.Contains("CECLIENT001", buildResult.StandardOutput + buildResult.StandardError, - StringComparison.Ordinal); + fixture.RequirePackages(); + foreach (PackageArchive archive in fixture.Archives) + { + Assert.Equal(fixture.ClientVersion, archive.Version); + string extension = archive.IsSymbolPackage ? "snupkg" : "nupkg"; + Assert.Equal($"{archive.Id}.{archive.Version}.{extension}", archive.FileName, ignoreCase: true); + } + + PackagedClientFeedFixture.Evidence(nameof(EveryClientPackageSharesOneVersion), $"version={fixture.ClientVersion}"); } - private static async Task BuildIsolatedPackageConsumerAsync(TemporaryDirectory temporary, string nuGetConfiguration, - string clientVersion) + [Fact] + public void SdkFacingPackagesDeclareThePinnedSdkRange() { - string consumerDirectory = temporary.CreateDirectory("package-consumer"); - string projectPath = Path.Combine(consumerDirectory, "Smoke.Plugin.csproj"); - await File.WriteAllTextAsync(projectPath, CreateConsumerProject(clientVersion), new UTF8Encoding(false)); - await File.WriteAllTextAsync(Path.Combine(consumerDirectory, "Plugin.cs"), ConsumerSource, - new UTF8Encoding(false)); - - await AssertDotNetSuccessAsync(consumerDirectory, "restore", projectPath, "--configfile", nuGetConfiguration); - - string deploymentDirectory = temporary.CreateDirectory("deployment"); - await AssertDotNetSuccessAsync(consumerDirectory, "build", projectPath, "--configuration", "Release", - "--no-restore", - $"-p:CheatEnginePluginOutputPath={deploymentDirectory}"); - await AssertDotNetSuccessAsync(consumerDirectory, "build", projectPath, "--configuration", "Release", - "--no-restore", - $"-p:CheatEnginePluginOutputPath={deploymentDirectory}"); - - string outputDirectory = Path.Combine(consumerDirectory, "bin", "Release", "net10.0"); - string[] requiredAssets = - [ - "Smoke.Plugin.dll", - "Smoke.Plugin.deps.json", - "Smoke.Plugin.runtimeconfig.json", - "cheatengine-sdk-lua-bridge.dll", - "CheatEngine.SDK.dll", - "CheatEngine.Client.Abstractions.dll", - "CheatEngine.Client.Core.dll", - "CheatEngine.Client.Fluent.dll", - "CheatEngine.Client.Extensions.DependencyInjection.dll", - "CheatEngine.Client.Hosting.dll" - ]; - Assert.All(requiredAssets, asset => + fixture.RequirePackages(); + string range = $"[{SdkPin.Version},{SdkPin.UpperBound})"; + foreach (string id in PackagedClientFeedFixture.PackageIds) { - Assert.True(File.Exists(Path.Combine(outputDirectory, asset)), - $"Isolated package-consumer output is missing '{asset}'."); - Assert.True(File.Exists(Path.Combine(deploymentDirectory, asset)), - $"Isolated plugin deployment is missing '{asset}'."); - }); - - string[] generatedEntryPoints = Directory.GetFiles(Path.Combine(consumerDirectory, "obj"), - "CheatEngine.SDK.EntryPoint.g.cs", SearchOption.AllDirectories); - string generatedEntryPoint = Assert.Single(generatedEntryPoints); - string generatedEntryPointText = await File.ReadAllTextAsync(generatedEntryPoint); - Assert.Contains("namespace CESDK", generatedEntryPointText, StringComparison.Ordinal); - Assert.Contains("CEPluginInitialize", generatedEntryPointText, StringComparison.Ordinal); + PackageDependency[] sdk = fixture.Package(id).Dependencies.Where(static dependency => dependency.Id == PackagedClientFeedFixture.SdkPackageId).ToArray(); + if (!SdkDependencyExclude.TryGetValue(id, out string? exclude)) + { + Assert.True(sdk.Length == 0, $"{id} must not depend on {PackagedClientFeedFixture.SdkPackageId} directly."); + continue; + } + + PackageDependency dependency = Assert.Single(sdk); + Assert.Equal(range, dependency.Version.Replace(" ", string.Empty, StringComparison.Ordinal)); + Assert.Equal(exclude, dependency.Exclude); + PackagedClientFeedFixture.Evidence(nameof(SdkFacingPackagesDeclareThePinnedSdkRange), $"package={id} sdk={dependency.Version} exclude={dependency.Exclude}"); + } } - private static async Task InstallInstantiateAndBuildTemplateAsync(TemporaryDirectory temporary, - string nuGetConfiguration, - string templatePackage) + [Fact] + public void InterClientDependenciesRequireTheExactCoPackedVersion() { - string templateHome = temporary.CreateDirectory("template-home"); - IReadOnlyDictionary environment = new Dictionary(StringComparer.Ordinal) + // A bare version in a nuspec is a minimum; DI and Hosting use Core and DI internals, so every edge is exact. + fixture.RequirePackages(); + string exact = $"[{fixture.ClientVersion}]"; + foreach (string id in PackagedClientFeedFixture.PackageIds) { - ["DOTNET_CLI_HOME"] = Path.Combine(templateHome, ".dotnet-cli"), - ["DOTNET_NEW_HOME"] = Path.Combine(templateHome, ".template-engine") - }; + PackageDependency[] client = fixture.Package(id).Dependencies + .Where(static dependency => dependency.Id.StartsWith(PackagedClientFeedFixture.ClientPackageId, StringComparison.Ordinal)) + .ToArray(); - await AssertDotNetSuccessAsync(templateHome, environment, "new", "install", templatePackage, "--force"); - await AssertDotNetSuccessAsync(templateHome, environment, "new", "ceplugin", "--dry-run", "--name", - "Smoke.Plugin", - "--output", Path.Combine(templateHome, "dry-run")); - - string instantiatedDirectory = Path.Combine(templateHome, "Smoke.Plugin"); - await AssertDotNetSuccessAsync(templateHome, environment, "new", "ceplugin", "--name", "Smoke.Plugin", - "--output", - instantiatedDirectory); - - string projectPath = Path.Combine(instantiatedDirectory, "Smoke.Plugin.csproj"); - Assert.True(File.Exists(projectPath), "Template instantiation did not produce the expected plugin project."); - await AssertDotNetSuccessAsync(instantiatedDirectory, environment, "restore", projectPath, "--configfile", - nuGetConfiguration); - await AssertDotNetSuccessAsync(instantiatedDirectory, environment, "build", projectPath, "--configuration", - "Release", - "--no-restore"); + Assert.Equal(InterClientDependencies[id], client.Select(static dependency => dependency.Id).Order(StringComparer.Ordinal)); + foreach (PackageDependency dependency in client) + { + Assert.True(dependency.Version == exact, + $"{id} depends on {dependency.Id} '{dependency.Version}', expected exactly the co-packed '{exact}'."); + PackagedClientFeedFixture.Evidence(nameof(InterClientDependenciesRequireTheExactCoPackedVersion), $"package={id} dependency={dependency.Id} version={dependency.Version}"); + } + } } - private static async Task AssertDotNetSuccessAsync(string workingDirectory, params string[] arguments) + [Fact] + public void HostingPackageShipsOnlyTheGeneratorAssemblyAsAnalyzer() { - await AssertDotNetSuccessAsync(workingDirectory, new Dictionary(StringComparer.Ordinal), - arguments); + fixture.RequirePackages(); + string[] analyzers = fixture.Package(PackagedClientFeedFixture.HostingPackageId).EntryNames + .Where(static entry => entry.StartsWith("analyzers/", StringComparison.Ordinal)).ToArray(); + + string[] expected = [GeneratorEntry]; + + Assert.Equal(expected, analyzers); + foreach (PackageArchive archive in fixture.Archives) + { + Assert.DoesNotContain(archive.EntryNames, static entry => Path.GetFileName(entry).StartsWith("Microsoft.CodeAnalysis", StringComparison.OrdinalIgnoreCase)); + } } - private static async Task AssertDotNetSuccessAsync(string workingDirectory, - IReadOnlyDictionary environment, - params string[] arguments) + [Fact] + public void PackedAssembliesCarryTheMajorMinorAssemblyVersion() { - DotNetProcessResult result = await DotNetProcess.RunAsync(workingDirectory, environment, arguments); - Assert.True(result.ExitCode == 0, result.ToString()); + fixture.RequirePackages(); + string[] parts = fixture.ClientVersion.Split('-')[0].Split('.'); + Version expected = parts[0] == "0" ? new Version(0, int.Parse(parts[1], System.Globalization.CultureInfo.InvariantCulture), 0, 0) : new Version(int.Parse(parts[0], System.Globalization.CultureInfo.InvariantCulture), 0, 0, 0); + int assemblies = 0; + foreach (PackageArchive archive in fixture.Archives.Where(static archive => !archive.IsSymbolPackage)) + { + foreach (string entry in archive.EntryNames.Where(static entry => entry.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))) + { + assemblies++; + using PEReader reader = new(new MemoryStream(archive.Entry(entry))); + Version version = reader.GetMetadataReader().GetAssemblyDefinition().Version; + Assert.True(version == expected, $"{archive.Id}/{entry} has AssemblyVersion {version}, expected {expected}."); + } + } + + Assert.Equal(6, assemblies); } - private static async Task ResolvePackageSourceAsync(TemporaryDirectory temporary) + [Fact] + public void PackedReadmesContainNoRelativeLinks() { - string? configuredPackageSource = Environment.GetEnvironmentVariable(PackageSourceEnvironmentVariable); - if (configuredPackageSource is not null) + fixture.RequirePackages(); + List offenders = []; + foreach (PackageArchive archive in fixture.Archives.Where(static archive => !archive.IsSymbolPackage)) { - Assert.False(string.IsNullOrWhiteSpace(configuredPackageSource), - $"{PackageSourceEnvironmentVariable} is set but empty. " + - "It must be an absolute directory containing prebuilt .nupkg files."); - Assert.True(Path.IsPathFullyQualified(configuredPackageSource), - $"{PackageSourceEnvironmentVariable} must be an absolute directory path, but was '{configuredPackageSource}'."); - Assert.True(Directory.Exists(configuredPackageSource), - $"{PackageSourceEnvironmentVariable} points to a missing directory: '{configuredPackageSource}'."); - return configuredPackageSource; + Assert.Equal("README.md", archive.MetadataValue("readme")); + string readme = archive.EntryText("README.md"); + foreach (Match link in LinkTarget().Matches(StripCode(readme))) + { + string target = link.Groups["target"].Value; + if (!target.StartsWith("https://", StringComparison.Ordinal)) + { + offenders.Add($"{archive.Id}: {target}"); + } + } } - string repositoryRoot = FindRepositoryRoot(); - string packageSource = temporary.CreateDirectory("packages"); - await AssertDotNetSuccessAsync(repositoryRoot, - "pack", Path.Combine(repositoryRoot, "CheatEngine.Client.slnx"), "--configuration", "Release", - "--output", packageSource); - return packageSource; + Assert.True(offenders.Count == 0, + $"nuget.org cannot resolve relative links in a packed README:{System.Environment.NewLine}{string.Join(System.Environment.NewLine, offenders)}"); } - private static PackageArchive FindPackage(string packageSource, string packageId) + [Fact] + public void EveryPackageNamesTheRepositoryCommitAndLicense() { - PackageArchive[] packages = Directory.GetFiles(packageSource, "*.nupkg") - .Select(ReadPackageArchive) - .ToArray(); - return Assert.Single(packages, package => package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + fixture.RequirePackages(); + HashSet commits = new(StringComparer.Ordinal); + HashSet descriptions = new(StringComparer.Ordinal); + foreach (PackageArchive archive in fixture.Archives.Where(static archive => !archive.IsSymbolPackage)) + { + XElement repository = Assert.IsType(archive.MetadataElement("repository")); + XElement license = Assert.IsType(archive.MetadataElement("license")); + string commit = (string?) repository.Attribute("commit") ?? string.Empty; + + Assert.Equal("git", (string?) repository.Attribute("type")); + Assert.Equal(RepositoryUrl, (string?) repository.Attribute("url")); + Assert.Matches("^[0-9a-f]{40}$", commit); + Assert.Equal("expression", (string?) license.Attribute("type")); + Assert.Equal("MIT", license.Value); + Assert.Equal(RepositoryUrl, archive.MetadataValue("projectUrl")); + Assert.Equal($"{RepositoryUrl}/blob/main/CHANGELOG.md", archive.MetadataValue("releaseNotes")); + Assert.StartsWith("Copyright (c) ", archive.MetadataValue("copyright"), StringComparison.Ordinal); + Assert.True(descriptions.Add(archive.MetadataValue("description") ?? string.Empty), $"{archive.Id} repeats another package's description."); + Assert.True(archive.Contains("README.md"), $"{archive.Id} has no README.md at the package root."); + commits.Add(commit); + } + + string single = Assert.Single(commits); + PackagedClientFeedFixture.Evidence(nameof(EveryPackageNamesTheRepositoryCommitAndLicense), $"commit={single}"); } - private static PackageArchive ReadPackageArchive(string packagePath) + [Fact] + public void SymbolPackagesCarrySourceLinkToTheRepositoryCommit() { - using ZipArchive archive = ZipFile.OpenRead(packagePath); - ZipArchiveEntry nuspec = Assert.Single(archive.Entries, - static entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); - using Stream stream = nuspec.Open(); - XDocument document = XDocument.Load(stream); - XNamespace packageNamespace = document.Root!.Name.Namespace; - XElement metadata = document.Root.Element(packageNamespace + "metadata") - ?? throw new InvalidOperationException( - $"Package '{packagePath}' does not declare metadata."); - string id = metadata.Element(packageNamespace + "id")?.Value - ?? throw new InvalidOperationException($"Package '{packagePath}' does not declare an id."); - string version = metadata.Element(packageNamespace + "version")?.Value - ?? throw new InvalidOperationException($"Package '{packagePath}' does not declare a version."); - return new PackageArchive(id, packagePath, version); + fixture.RequirePackages(); + string commit = (string) fixture.Package(PackagedClientFeedFixture.ClientPackageId).MetadataElement("repository")!.Attribute("commit")!; + string expectedPrefix = $"https://raw.githubusercontent.com/CheatEngineNet/CheatEngine.Client/{commit}/"; + foreach (PackageArchive symbol in fixture.Archives.Where(static archive => archive.IsSymbolPackage)) + { + foreach (string pdb in symbol.EntryNames.Where(static entry => entry.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase))) + { + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(new MemoryStream(symbol.Entry(pdb))); + MetadataReader reader = provider.GetMetadataReader(); + string? sourceLink = null; + foreach (CustomDebugInformationHandle handle in reader.GetCustomDebugInformation(EntityHandle.ModuleDefinition)) + { + CustomDebugInformation information = reader.GetCustomDebugInformation(handle); + if (reader.GetGuid(information.Kind) == SourceLinkKind) + { + sourceLink = Encoding.UTF8.GetString(reader.GetBlobBytes(information.Value)); + } + } + + Assert.True(sourceLink is not null, $"{symbol.Id}/{pdb} has no Source Link information."); + using JsonDocument documents = JsonDocument.Parse(sourceLink!); + foreach (JsonProperty mapping in documents.RootElement.GetProperty("documents").EnumerateObject()) + { + Assert.StartsWith(expectedPrefix, mapping.Value.GetString(), StringComparison.Ordinal); + } + } + } } - private static void AssertArchiveContains(string packagePath, params string[] expectedEntries) + [Fact] + public void EveryPackageEmbedsAnSpdxSbomDescribingItsOwnIdentity() + { + fixture.RequirePackages(); + foreach (PackageArchive archive in fixture.Archives.Where(static archive => !archive.IsSymbolPackage)) + { + using JsonDocument sbom = JsonDocument.Parse(archive.Entry(SbomEntry)); + JsonElement root = sbom.RootElement; + Assert.Equal("SPDX-2.2", root.GetProperty("spdxVersion").GetString()); + JsonElement described = Assert.Single(root.GetProperty("packages").EnumerateArray(), + static package => package.GetProperty("SPDXID").GetString() == "SPDXRef-RootPackage"); + Assert.Equal(archive.Id, described.GetProperty("name").GetString()); + Assert.Equal(archive.Version, described.GetProperty("versionInfo").GetString()); + Assert.StartsWith($"{RepositoryUrl}/{archive.Id}/{archive.Version}/", root.GetProperty("documentNamespace").GetString(), StringComparison.Ordinal); + + Dictionary files = new(StringComparer.Ordinal); + foreach (JsonElement file in root.GetProperty("files").EnumerateArray()) + { + string sha256 = file.GetProperty("checksums").EnumerateArray() + .Single(static checksum => checksum.GetProperty("algorithm").GetString() == "SHA256").GetProperty("checksumValue").GetString()!; + files[file.GetProperty("fileName").GetString()!.TrimStart('.', '/')] = sha256; + } + + foreach (string entry in archive.EntryNames.Where(static entry => entry.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))) + { + Assert.True(files.TryGetValue(entry, out string? recorded), $"The SBOM of {archive.Id} does not list {entry}."); + Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(archive.Entry(entry))), recorded, ignoreCase: true); + } + + PackagedClientFeedFixture.Evidence(nameof(EveryPackageEmbedsAnSpdxSbomDescribingItsOwnIdentity), + $"package={archive.Id} sbomSha256={Convert.ToHexStringLower(SHA256.HashData(archive.Entry(SbomEntry)))} files={files.Count}"); + } + } + + [Fact] + public void PackedTemplateReferencesTheCoPackedClientAndThePinnedSdk() + { + fixture.RequirePackages(); + XDocument project = XDocument.Parse(fixture.Package(PackagedClientFeedFixture.TemplatePackageId).EntryText(TemplateProjectEntry)); + + Assert.Equal(fixture.ClientVersion, PackageReferenceVersion(project, PackagedClientFeedFixture.ClientPackageId)); + Assert.Equal(SdkPin.Version, PackageReferenceVersion(project, PackagedClientFeedFixture.SdkPackageId)); + Assert.Single(fixture.Package(PackagedClientFeedFixture.TemplatePackageId).EntryNames, static entry => entry.EndsWith(".csproj", StringComparison.Ordinal)); + } + + [Fact] + public void PackedTemplateProjectDiffersFromTheRepositoryTemplateOnlyByStampedVersions() + { + fixture.RequirePackages(); + string packed = fixture.Package(PackagedClientFeedFixture.TemplatePackageId).EntryText(TemplateProjectEntry); + string repository = File.ReadAllText(RepositoryLayout.Combine($"templates/CheatEngine.Client.Templates/{TemplateProjectEntry}")); + + Assert.Equal(3, PackageReferenceVersionAttribute().Count(packed)); + Assert.Equal(PackageReferenceVersionAttribute().Replace(repository, "${prefix}*${suffix}"), + PackageReferenceVersionAttribute().Replace(packed, "${prefix}*${suffix}")); + } + + [Fact] + public async Task TemplatePackageInstallsListsAndUninstallsAsync() { - using ZipArchive archive = ZipFile.OpenRead(packagePath); - HashSet entries = archive.Entries.Select(static entry => entry.FullName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - Assert.All(expectedEntries, entry => Assert.Contains(entry, entries, StringComparer.OrdinalIgnoreCase)); + fixture.RequirePackages(); + string home = fixture.CreateDirectory("template-lifecycle"); + Dictionary isolatedHome = new(StringComparer.Ordinal) + { + ["DOTNET_CLI_HOME"] = Path.Combine(home, "cli-home"), + ["DOTNET_NEW_HOME"] = Path.Combine(home, "template-engine") + }; + string package = fixture.Package(PackagedClientFeedFixture.TemplatePackageId).Path; + + DotNetProcessResult install = await fixture.RunAsync(home, isolatedHome, "new", "install", package); + DotNetProcessResult list = await fixture.RunAsync(home, isolatedHome, "new", "list", "ceplugin"); + DotNetProcessResult installed = await fixture.RunAsync(home, isolatedHome, "new", "uninstall"); + DotNetProcessResult uninstall = await fixture.RunAsync(home, isolatedHome, "new", "uninstall", PackagedClientFeedFixture.TemplatePackageId); + DotNetProcessResult remaining = await fixture.RunAsync(home, isolatedHome, "new", "uninstall"); + + Assert.True(install.ExitCode == 0, install.ToString()); + Assert.True(list.ExitCode == 0 && list.StandardOutput.Contains("ceplugin", StringComparison.Ordinal), list.ToString()); + Assert.Contains(PackagedClientFeedFixture.TemplatePackageId, installed.StandardOutput, StringComparison.Ordinal); + Assert.Contains(fixture.ClientVersion, installed.StandardOutput, StringComparison.Ordinal); + Assert.True(uninstall.ExitCode == 0, uninstall.ToString()); + Assert.DoesNotContain(PackagedClientFeedFixture.TemplatePackageId, remaining.StandardOutput, StringComparison.Ordinal); } - private static string WriteNuGetConfiguration(TemporaryDirectory temporary, string packageSource) + [Fact] + public void IsolatedConsumerResolvesClientPackagesOnlyFromTheLocalFeed() { - string packageCache = temporary.CreateDirectory("packages-cache"); - string path = Path.Combine(temporary.Path, "NuGet.Config"); - string configuration = $""" - - - - - - - - - - - - """; - File.WriteAllText(path, configuration, new UTF8Encoding(false)); - return path; + fixture.RequireConsumer(); + string feed = Path.TrimEndingDirectorySeparator(Path.GetFullPath(fixture.PackageSource)); + string[] clientFolders = Directory.GetDirectories(fixture.PackageCache, "cheatengine.client*"); + Assert.Equal(6, clientFolders.Length); + foreach (string folder in clientFolders) + { + string version = Assert.Single(Directory.GetDirectories(folder)); + Assert.Equal(fixture.ClientVersion, Path.GetFileName(version), ignoreCase: true); + using JsonDocument metadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(version, ".nupkg.metadata"))); + string source = Path.TrimEndingDirectorySeparator(metadata.RootElement.GetProperty("source").GetString()!); + Assert.True(string.Equals(source, feed, StringComparison.OrdinalIgnoreCase), + $"{Path.GetFileName(folder)} was restored from '{source}', not from the local package directory '{feed}'."); + } + + string sdkFolder = Path.Combine(fixture.PackageCache, "cheatengine.sdk", fixture.SdkVersion.ToLowerInvariant()); + using JsonDocument sdkMetadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(sdkFolder, ".nupkg.metadata"))); + string contentHash = sdkMetadata.RootElement.GetProperty("contentHash").GetString()!; + if (fixture.UsesPinnedSdk) + { + Assert.Equal("https://api.nuget.org/v3/index.json", sdkMetadata.RootElement.GetProperty("source").GetString()); + Assert.Equal(fixture.ConsumedSdk.GetProperty("contentHashSha512").GetString(), contentHash); + } + + PackagedClientFeedFixture.Evidence(nameof(IsolatedConsumerResolvesClientPackagesOnlyFromTheLocalFeed), + $"sdk={fixture.SdkVersion} sdkSource={sdkMetadata.RootElement.GetProperty("source").GetString()} sdkContentHashSha512={contentHash}"); } - private static string EscapeXml(string value) + [Fact] + public void IsolatedConsumerDeploysTheCompleteClosureWithThePackagedBridge() { - return SecurityElement.Escape(value) ?? - throw new InvalidOperationException("Could not escape NuGet configuration."); + fixture.RequireConsumer(); + string bridge = PackagedBridgeSha256(); + string[] required = + [ + $"{PackagedClientFeedFixture.ConsumerName}.dll", $"{PackagedClientFeedFixture.ConsumerName}.deps.json", + $"{PackagedClientFeedFixture.ConsumerName}.runtimeconfig.json", PackagedClientFeedFixture.BridgeFileName, + .. PackagedClientFeedFixture.ClientAssemblies + ]; + foreach (string directory in (string[]) [fixture.ConsumerOutput, fixture.DeploymentDirectory]) + { + foreach (string asset in required) + { + Assert.True(File.Exists(Path.Combine(directory, asset)), $"'{directory}' is missing '{asset}'."); + } + + Assert.Equal(bridge, FileSha256(Path.Combine(directory, PackagedClientFeedFixture.BridgeFileName))); + } + + if (fixture.UsesPinnedSdk) + { + Assert.Equal(fixture.ConsumedSdk.GetProperty("nativeBridge").GetProperty("sha256").GetString(), bridge); + } + + PackagedClientFeedFixture.Evidence(nameof(IsolatedConsumerDeploysTheCompleteClosureWithThePackagedBridge), $"bridgeSha256={bridge}"); } - private static string FindRepositoryRoot() + [Fact] + public void IsolatedConsumerDepsJsonRecordsPackagesWithoutWorkspacePaths() { - for (DirectoryInfo? candidate = new(AppContext.BaseDirectory); - candidate is not null; - candidate = candidate.Parent) + fixture.RequireConsumer(); + string depsPath = Path.Combine(fixture.ConsumerOutput, $"{PackagedClientFeedFixture.ConsumerName}.deps.json"); + string runtimeConfigPath = Path.Combine(fixture.ConsumerOutput, $"{PackagedClientFeedFixture.ConsumerName}.runtimeconfig.json"); + string depsText = File.ReadAllText(depsPath); + using JsonDocument deps = JsonDocument.Parse(depsText); + JsonElement libraries = deps.RootElement.GetProperty("libraries"); + + JsonElement sdk = libraries.GetProperty($"{PackagedClientFeedFixture.SdkPackageId}/{fixture.SdkVersion}"); + Assert.Equal("package", sdk.GetProperty("type").GetString()); + foreach (string id in PackagedClientFeedFixture.PackageIds.Where(static id => id != PackagedClientFeedFixture.TemplatePackageId)) + { + Assert.Equal("package", libraries.GetProperty($"{id}/{fixture.ClientVersion}").GetProperty("type").GetString()); + } + + // Measured, not assumed (audit A21-02): the deps.json library entry carries the NuGet content hash NuGet recorded + // in .nupkg.metadata, the value a lock file holds, not the SHA-512 of the repository-signed file + // (..nupkg.sha512). A deployed plugin can therefore be tied to the Client tuple's consumedSdk. + string versionFolder = Path.Combine(fixture.PackageCache, "cheatengine.sdk", fixture.SdkVersion.ToLowerInvariant()); + using JsonDocument metadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(versionFolder, ".nupkg.metadata"))); + string contentHash = metadata.RootElement.GetProperty("contentHash").GetString()!; + string signedSha512 = File.ReadAllText(Path.Combine(versionFolder, $"cheatengine.sdk.{fixture.SdkVersion.ToLowerInvariant()}.nupkg.sha512")).Trim(); + Assert.Equal($"sha512-{contentHash}", sdk.GetProperty("sha512").GetString()); + if (fixture.UsesPinnedSdk) { - if (File.Exists(Path.Combine(candidate.FullName, "CheatEngine.Client.slnx"))) + Assert.Equal(fixture.ConsumedSdk.GetProperty("contentHashSha512").GetString(), contentHash); + Assert.Equal(fixture.ConsumedSdk.GetProperty("nugetOrgSignedSha512").GetString(), signedSha512); + Assert.NotEqual(contentHash, signedSha512); + } + + foreach (string text in (string[]) [depsText, File.ReadAllText(runtimeConfigPath)]) + { + foreach (string workspace in WorkspaceSpellings()) { - return candidate.FullName; + Assert.DoesNotContain(workspace, text, StringComparison.OrdinalIgnoreCase); } } - throw new DirectoryNotFoundException( - "Could not find the CheatEngine.Client repository root from the test output."); + PackagedClientFeedFixture.Evidence(nameof(IsolatedConsumerDepsJsonRecordsPackagesWithoutWorkspacePaths), + $"depsSha256={FileSha256(depsPath)} sdkLibrarySha512=sha512-{contentHash} (the NuGet content hash of the lock, not the signed-file SHA-512 {signedSha512})"); } - private static string CreateConsumerProject(string clientVersion, bool hasDirectSdkPackageReference = true) + [Fact] + public void InstantiatedTemplateReferencesTheSdkDirectly() { - string sdkPackageReference = hasDirectSdkPackageReference - ? " " - : string.Empty; - - return $$""" - - - net10.0 - 14.0 - enable - enable - x64 - true - false - true - obj/Generated - - - - {{sdkPackageReference}} - - - """; + fixture.RequireTemplate(); + XDocument project = XDocument.Load(Path.Combine(fixture.TemplateDirectory, $"{PackagedClientFeedFixture.ConsumerName}.csproj")); + + Assert.Equal(fixture.ClientVersion, PackageReferenceVersion(project, PackagedClientFeedFixture.ClientPackageId)); + Assert.Equal(SdkPin.Version, PackageReferenceVersion(project, PackagedClientFeedFixture.SdkPackageId)); + Assert.Equal("true", project.Descendants("CheatEngineClientPluginProject").Single().Value); } - private sealed record PackageArchive(string Id, string Path, string Version); + [Fact] + public void InstantiatedTemplateBuildsTheCompleteDeploymentClosure() + { + fixture.RequireTemplate(); + string[] required = + [ + $"{PackagedClientFeedFixture.ConsumerName}.dll", $"{PackagedClientFeedFixture.ConsumerName}.deps.json", + $"{PackagedClientFeedFixture.ConsumerName}.runtimeconfig.json", PackagedClientFeedFixture.BridgeFileName, + .. PackagedClientFeedFixture.ClientAssemblies + ]; + foreach (string asset in required) + { + Assert.True(File.Exists(Path.Combine(fixture.TemplateOutput, asset)), $"The instantiated template output is missing '{asset}'."); + } + + Assert.Equal(PackagedBridgeSha256(), FileSha256(Path.Combine(fixture.TemplateOutput, PackagedClientFeedFixture.BridgeFileName))); + } + + [Fact] + public async Task PackagedClientAndTemplateCanBeInstalledInstantiatedAndBuiltInIsolatedDirectoriesAsync() + { + fixture.RequireConsumer(); + fixture.RequireTemplate(); + PackagedClientFeedFixture.AssertOutsideAnyRepository(fixture.ConsumerDirectory); + PackagedClientFeedFixture.AssertOutsideAnyRepository(fixture.TemplateDirectory); + + string generatedEntryPoint = Assert.Single(Directory.GetFiles(Path.Combine(fixture.ConsumerDirectory, "obj"), + "CheatEngine.SDK.EntryPoint.g.cs", SearchOption.AllDirectories)); + string text = await File.ReadAllTextAsync(generatedEntryPoint, TestContext.Current.CancellationToken); + Assert.Contains("namespace CESDK", text, StringComparison.Ordinal); + Assert.Contains("CEPluginInitialize", text, StringComparison.Ordinal); + PackagedClientFeedFixture.Evidence(nameof(PackagedClientAndTemplateCanBeInstalledInstantiatedAndBuiltInIsolatedDirectoriesAsync), + $"source={fixture.SourceKind} client={fixture.ClientVersion} sdk={fixture.SdkVersion}"); + } + + [Fact] + public async Task PackagedClientPluginWithoutDirectSdkReferenceReportsCECLIENT001Async() + { + fixture.RequirePackages(); + string consumer = fixture.CreateDirectory("missing-sdk-package-consumer"); + string project = Path.Combine(consumer, "MissingSdk.Plugin.csproj"); + await File.WriteAllTextAsync(project, PackagedClientFeedFixture.CreateConsumerProject(fixture.ClientVersion, null), + new UTF8Encoding(false), TestContext.Current.CancellationToken); + + DotNetProcessResult restore = await fixture.RunAsync(consumer, "restore", project, "--configfile", fixture.NuGetConfiguration, + "--packages", fixture.PackageCache); + DotNetProcessResult build = await fixture.RunAsync(consumer, "build", project, "--configuration", "Release", "--no-restore", + "-p:UseSharedCompilation=false"); + + Assert.True(restore.ExitCode == 0, restore.ToString()); + Assert.True(build.ExitCode != 0, build.ToString()); + Assert.Contains("CECLIENT001", build.StandardOutput + build.StandardError, StringComparison.Ordinal); + } + + [Fact] + public async Task PluginReferencingTheNextSdkMajorReportsCECLIENT017Async() + { + fixture.RequireConsumer(); + Assert.True(fixture.UsesPinnedSdk, $"This fact re-versions the pinned SDK; unset {PackagedClientFeedFixture.SdkPackageSourceVariable}."); + string feed = fixture.CreateDirectory("next-major-sdk-feed"); + string nextMajor = $"{SdkPin.UpperMajor}.0.0"; + + // The exclusive upper bound of the declared range is the first unsupported major. A prerelease of it sorts below + // it, so it satisfies [pin, upper bound) and NuGet resolves it without any warning; the stable release only + // triggers the NU1608 warning. Both must fail the plugin build. + foreach ((string version, bool nuGetWarns) in (ValueTuple[]) [($"{nextMajor}-cecanary.1", false), (nextMajor, true)]) + { + fixture.CreateReversionedSdkPackage(feed, version); + string consumer = fixture.CreateDirectory($"next-major-sdk-consumer-{version}"); + string project = Path.Combine(consumer, "NextMajorSdk.Plugin.csproj"); + await File.WriteAllTextAsync(project, PackagedClientFeedFixture.CreateConsumerProject(fixture.ClientVersion, version), + new UTF8Encoding(false), TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(consumer, "Plugin.cs"), PackagedClientFeedFixture.ConsumerSource, + new UTF8Encoding(false), TestContext.Current.CancellationToken); + string configuration = PackagedClientFeedFixture.WriteNuGetConfiguration(Path.Combine(consumer, "NuGet.Config"), + fixture.PackageCache, fixture.PackageSource, feed); + + DotNetProcessResult restore = await fixture.RunAsync(consumer, "restore", project, "--configfile", configuration, + "--packages", fixture.PackageCache); + DotNetProcessResult refused = await fixture.RunAsync(consumer, "build", project, "--configuration", "Release", + "--no-restore", "-p:UseSharedCompilation=false"); + DotNetProcessResult allowed = await fixture.RunAsync(consumer, "build", project, "--configuration", "Release", + "--no-restore", "-p:UseSharedCompilation=false", "-p:CheatEngineClientAllowUnsupportedSdk=true"); + + Assert.True(restore.ExitCode == 0, restore.ToString()); + Assert.Equal(nuGetWarns, restore.StandardOutput.Contains("NU1608", StringComparison.Ordinal)); + Assert.True(refused.ExitCode != 0, refused.ToString()); + Assert.Contains("error CECLIENT017", refused.StandardOutput, StringComparison.Ordinal); + Assert.True(allowed.ExitCode == 0, allowed.ToString()); + Assert.Contains("warning CECLIENT017", allowed.StandardOutput, StringComparison.Ordinal); + PackagedClientFeedFixture.Evidence(nameof(PluginReferencingTheNextSdkMajorReportsCECLIENT017Async), + $"sdk={version} nu1608={nuGetWarns} build=CECLIENT017 error; opt-out=CECLIENT017 warning"); + } + } + + [Fact] + public async Task PluginReferencingAnSdkBelowTheDeclaredRangeFailsRestoreAsync() + { + fixture.RequireConsumer(); + Assert.True(fixture.UsesPinnedSdk, $"This fact re-versions the pinned SDK; unset {PackagedClientFeedFixture.SdkPackageSourceVariable}."); + string feed = fixture.CreateDirectory("below-range-sdk-feed"); + string range = $"[{SdkPin.Version}, {SdkPin.UpperBound})"; + // A late release of the previous major (0.99.0 while the pin is 1.x): below the lower bound of the declared range. + string version = $"{Math.Max(SdkPin.Major - 1, 0)}.99.0"; + fixture.CreateReversionedSdkPackage(feed, version); + string consumer = fixture.CreateDirectory("below-range-sdk-consumer"); + string project = Path.Combine(consumer, "BelowRangeSdk.Plugin.csproj"); + await File.WriteAllTextAsync(project, PackagedClientFeedFixture.CreateConsumerProject(fixture.ClientVersion, version), + new UTF8Encoding(false), TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(consumer, "Plugin.cs"), PackagedClientFeedFixture.ConsumerSource, + new UTF8Encoding(false), TestContext.Current.CancellationToken); + string configuration = PackagedClientFeedFixture.WriteNuGetConfiguration(Path.Combine(consumer, "NuGet.Config"), + fixture.PackageCache, fixture.PackageSource, feed); + + DotNetProcessResult restore = await fixture.RunAsync(consumer, "restore", project, "--configfile", configuration, + "--packages", fixture.PackageCache); + + // The direct reference wins over the SDK-facing Client packages' dependency on the declared range, which NuGet + // reports as a package downgrade; the .NET SDK treats NU1605 as an error, so the restore fails before any build. + Assert.True(restore.ExitCode != 0, restore.ToString()); + Assert.Contains("error NU1605", restore.StandardOutput, StringComparison.Ordinal); + PackagedClientFeedFixture.Evidence(nameof(PluginReferencingAnSdkBelowTheDeclaredRangeFailsRestoreAsync), + $"sdk={version} declared={range} restore=NU1605 error"); + } + + private string PackagedBridgeSha256() + { + string version = fixture.SdkVersion.ToLowerInvariant(); + PackageArchive sdk = PackageArchive.Read(Path.Combine(fixture.PackageCache, "cheatengine.sdk", version, $"cheatengine.sdk.{version}.nupkg")); + return Convert.ToHexStringLower(SHA256.HashData(sdk.Entry($"build/native/{PackagedClientFeedFixture.BridgeFileName}"))); + } + + private static string? PackageReferenceVersion(XDocument project, string id) + { + return (string?) project.Descendants("PackageReference").Single(reference => (string?) reference.Attribute("Include") == id).Attribute("Version"); + } + + private static string FileSha256(string path) + { + return Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + } + + private static IEnumerable WorkspaceSpellings() + { + string root = Path.TrimEndingDirectorySeparator(RepositoryLayout.Root); + yield return root.Replace("\\", "\\\\", StringComparison.Ordinal); + yield return root.Replace('\\', '/'); + } + + private static string StripCode(string markdown) + { + return InlineCode().Replace(FencedCode().Replace(markdown, string.Empty), string.Empty); + } + + [GeneratedRegex(@"(?:\]\(\s*[^)\s>]+)|(?:href|src)\s*=\s*[""'](?[^""']+))", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex LinkTarget(); + + [GeneratedRegex(@"^[ \t]*(`{3,}|~{3,})[^\n]*\n.*?^[ \t]*\1[ \t]*$", RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.Singleline, RegexTimeoutMilliseconds)] + private static partial Regex FencedCode(); + + [GeneratedRegex(@"`[^`\n]*`", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex InlineCode(); + + [GeneratedRegex(@"(?"")", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex PackageReferenceVersionAttribute(); } diff --git a/tests/CheatEngine.Client.Tests/Packaging/PackageSourceResolution.cs b/tests/CheatEngine.Client.Tests/Packaging/PackageSourceResolution.cs new file mode 100644 index 0000000..fb7e743 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/PackageSourceResolution.cs @@ -0,0 +1,71 @@ +namespace CheatEngine.Client.Tests.Packaging; + +/// How the package consumption tests obtain the packages they consume. +internal enum PackageSourceKind +{ + /// The exact package directory named by CHEATENGINE_CLIENT_PACKAGE_SOURCE. + ConfiguredDirectory, + + /// A local run without a configured directory: the tests pack the repository into a temporary feed. + SelfPack, + + /// The configuration cannot be used; says why and how to fix it. + Invalid +} + +/// The outcome of . +internal sealed record PackageSourceDecision(PackageSourceKind Kind, string? Directory, string? Error); + +/// +/// Chooses the package directory of the consumption tests. In continuous integration the tests must consume the packages +/// the Release leg packed and uploaded, never a package they build themselves, so a missing directory is a failure there. +/// +internal static class PackageSourceResolution +{ + /// The environment variable that names the exact package directory. + internal const string PackageSourceVariable = "CHEATENGINE_CLIENT_PACKAGE_SOURCE"; + + /// Reads the process environment and resolves the package source. + internal static PackageSourceDecision ResolveFromEnvironment() + { + return Resolve(Environment.GetEnvironmentVariable(PackageSourceVariable), + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase), + Directory.Exists); + } + + /// Resolves the package source from explicit inputs, so the rules are testable without an environment. + internal static PackageSourceDecision Resolve(string? configured, bool continuousIntegration, Func directoryExists) + { + ArgumentNullException.ThrowIfNull(directoryExists); + + if (configured is null) + { + return continuousIntegration + ? new PackageSourceDecision(PackageSourceKind.Invalid, null, + $"{PackageSourceVariable} is not set, but CI=true. In continuous integration the package consumption tests " + + "must consume the exact packages the Release leg packed: the Release leg of .github/workflows/ci.yml sets " + + $"{PackageSourceVariable} to the absolute artifacts/nuget directory of its Pack step, and the Debug leg " + + "excludes these tests with --filter-not-trait \"Category=PackageConsumption\". Packing here would test a " + + "different package from the one CI uploads.") + : new PackageSourceDecision(PackageSourceKind.SelfPack, null, null); + } + + if (string.IsNullOrWhiteSpace(configured)) + { + return new PackageSourceDecision(PackageSourceKind.Invalid, null, + $"{PackageSourceVariable} is set but empty. Set it to the absolute directory that holds the packed .nupkg " + + "files (dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget), or unset it locally."); + } + + if (!Path.IsPathFullyQualified(configured)) + { + return new PackageSourceDecision(PackageSourceKind.Invalid, null, + $"{PackageSourceVariable} must be an absolute directory path, but is '{configured}'."); + } + + return directoryExists(configured) + ? new PackageSourceDecision(PackageSourceKind.ConfiguredDirectory, Path.GetFullPath(configured), null) + : new PackageSourceDecision(PackageSourceKind.Invalid, null, + $"{PackageSourceVariable} points to a missing directory: '{configured}'."); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/PackageSourceResolutionTests.cs b/tests/CheatEngine.Client.Tests/Packaging/PackageSourceResolutionTests.cs new file mode 100644 index 0000000..aa34090 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/PackageSourceResolutionTests.cs @@ -0,0 +1,49 @@ +namespace CheatEngine.Client.Tests.Packaging; + +/// +/// The package source rules, without packing anything. Deliberately not in the PackageConsumption category, so both CI +/// legs run them. +/// +public sealed class PackageSourceResolutionTests +{ + private static readonly string Existing = Path.GetFullPath(Path.GetTempPath()); + + [Fact] + public void MissingPackageSourceFailsInContinuousIntegration() + { + PackageSourceDecision decision = PackageSourceResolution.Resolve(null, true, static _ => true); + + Assert.Equal(PackageSourceKind.Invalid, decision.Kind); + Assert.Contains(PackageSourceResolution.PackageSourceVariable, decision.Error, StringComparison.Ordinal); + Assert.Contains("Release leg", decision.Error, StringComparison.Ordinal); + Assert.Contains("--filter-not-trait \"Category=PackageConsumption\"", decision.Error, StringComparison.Ordinal); + } + + [Fact] + public void MissingPackageSourceSelfPacksLocally() + { + PackageSourceDecision decision = PackageSourceResolution.Resolve(null, false, static _ => true); + + Assert.Equal(PackageSourceKind.SelfPack, decision.Kind); + Assert.Null(decision.Directory); + Assert.Null(decision.Error); + } + + [Fact] + public void RelativeOrMissingPackageSourceIsRejected() + { + PackageSourceDecision empty = PackageSourceResolution.Resolve(" ", false, static _ => true); + PackageSourceDecision relative = PackageSourceResolution.Resolve("artifacts/nuget", true, static _ => true); + PackageSourceDecision missing = PackageSourceResolution.Resolve(Existing, true, static _ => false); + PackageSourceDecision configured = PackageSourceResolution.Resolve(Existing, true, static _ => true); + + Assert.Equal(PackageSourceKind.Invalid, empty.Kind); + Assert.Contains("empty", empty.Error, StringComparison.Ordinal); + Assert.Equal(PackageSourceKind.Invalid, relative.Kind); + Assert.Contains("absolute", relative.Error, StringComparison.Ordinal); + Assert.Equal(PackageSourceKind.Invalid, missing.Kind); + Assert.Contains("missing directory", missing.Error, StringComparison.Ordinal); + Assert.Equal(PackageSourceKind.ConfiguredDirectory, configured.Kind); + Assert.Equal(Existing, configured.Directory); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/PackagedClientFeedFixture.cs b/tests/CheatEngine.Client.Tests/Packaging/PackagedClientFeedFixture.cs new file mode 100644 index 0000000..f631d1c --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/PackagedClientFeedFixture.cs @@ -0,0 +1,592 @@ +using System.IO.Compression; +using System.Security; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.Packaging; + +/// The serial collection of the package consumption tests, sharing one packed feed and one set of consumers. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class PackageConsumptionSmokeSerialGroup : ICollectionFixture +{ + /// The collection name. + public const string Name = "Package consumption smoke"; +} + +/// One recorded dotnet step of the fixture. +internal sealed record SmokeStep(string Name, DotNetProcessResult Result) +{ + /// Whether the step exited with 0. + internal bool Succeeded => Result.ExitCode == 0; +} + +/// +/// Resolves (or, locally, packs) the Client package directory once, reads every archive, and restores and builds an +/// isolated package consumer and an instantiated template once, in directories outside any repository, with an +/// isolated NuGet global packages folder and package source mapping. Each fact then asserts on the recorded results, so +/// the collection stays well inside the CI hang-dump inactivity window. A failed step is recorded, not thrown, so the +/// archive facts still report on the packages. +/// +public sealed class PackagedClientFeedFixture : IAsyncLifetime +{ + /// The umbrella package. + internal const string ClientPackageId = "CheatEngine.Client"; + + /// The Hosting package, which carries the generator and the consumer build targets. + internal const string HostingPackageId = "CheatEngine.Client.Hosting"; + + /// The template package. + internal const string TemplatePackageId = "CheatEngine.Client.Templates"; + + /// The SDK package. + internal const string SdkPackageId = "CheatEngine.SDK"; + + /// The native bridge inside the SDK package and next to a built plugin. + internal const string BridgeFileName = "cheatengine-sdk-lua-bridge.dll"; + + /// The consumer project name. + internal const string ConsumerName = "Smoke.Plugin"; + + /// Optional: a directory holding exactly one CheatEngine.SDK package to consume instead of nuget.org's pin. + internal const string SdkPackageSourceVariable = "CHEATENGINE_SDK_PACKAGE_SOURCE"; + + /// The seven packages, one lockstep version. + internal static readonly string[] PackageIds = + [ + "CheatEngine.Client", "CheatEngine.Client.Abstractions", "CheatEngine.Client.Core", + "CheatEngine.Client.Extensions.DependencyInjection", "CheatEngine.Client.Fluent", "CheatEngine.Client.Hosting", + "CheatEngine.Client.Templates" + ]; + + /// The packages with build output, hence with a symbol package (not the facade, not the template package). + internal static readonly string[] SymbolPackageIds = + [ + "CheatEngine.Client.Abstractions", "CheatEngine.Client.Core", "CheatEngine.Client.Extensions.DependencyInjection", + "CheatEngine.Client.Fluent", "CheatEngine.Client.Hosting" + ]; + + /// The managed closure every plugin output and deployment folder must hold. + internal static readonly string[] ClientAssemblies = + [ + "CheatEngine.SDK.dll", "CheatEngine.Client.Abstractions.dll", "CheatEngine.Client.Core.dll", + "CheatEngine.Client.Fluent.dll", "CheatEngine.Client.Extensions.DependencyInjection.dll", "CheatEngine.Client.Hosting.dll" + ]; + + /// The plugin source of every consumer project. + internal const string ConsumerSource = """ + using CheatEngine.Client; + using CheatEngine.Client.Hosting; + using CheatEngine.Client.Memory; + using CheatEngine.Client.Scanning; + using CheatEngine.SDK.Annotations.Plugin; + using CheatEngine.SDK.Engine.Values; + + [CheatEnginePlugin("Package smoke plugin")] + public sealed class Plugin : CheatEngineClientPlugin + { + protected override void Configure(CheatEnginePluginBuilder builder) + { + } + + protected override void OnClientEnabled(ICheatEngineClient client) + { + _ = client.Memory.At(default(Address)); + _ = client.Patterns.Aob("00").FirstOrNone(); + } + } + """; + + /// + /// The reviewed identity of the published CheatEngine.SDK 2.0.0, verified 2026-09-24: + /// the NuGet content hash a lock file records, the nuget.org repository-signed file's SHA-512, and the SHA-256 of + /// the native bridge packaged inside it. Hardcoded, not read from a reviewed-identity file: the sync mechanism + /// that used to keep such a file current was removed, and the Client's SDK pin (eng/CheatEngineSdk.props) does not + /// move without updating these literals in the same change. + /// + private static readonly JsonDocument PinnedSdkIdentity = JsonDocument.Parse(""" + { + "version": "2.0.0", + "contentHashSha512": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==", + "nugetOrgSignedSha512": "R5aMq17JFx5tU6kEk9WR1ySFgP6W90yA2Hmowbn3BRQyI7MVe5429mSRu/Crhk4wROwdtK9IhACRwcCtukGY+Q==", + "nativeBridge": { "sha256": "b008c8d8c136187f241542e6223dc0831999d8300dc2c4c01e1cf49f6fba7698" } + } + """); + + private readonly List _steps = []; + private TemporaryDirectory? _temporary; + private Dictionary _environment = new(StringComparer.Ordinal); + private bool _hasConsumedSdkIdentity; + + /// Why the packages are unusable, or . + internal string? SetupFailure + { + get; + private set; + } + + /// How the package directory was obtained. + internal PackageSourceKind SourceKind + { + get; + private set; + } + + /// The absolute package directory the tests consume. + internal string PackageSource + { + get; + private set; + } = string.Empty; + + /// Every archive of . + internal IReadOnlyList Archives + { + get; + private set; + } = []; + + /// The version every Client package carries. + internal string ClientVersion + { + get; + private set; + } = string.Empty; + + /// The CheatEngine.SDK version the consumers reference directly. + internal string SdkVersion + { + get; + private set; + } = string.Empty; + + /// Whether the consumers use the committed pin from nuget.org (so the reviewed SDK identity applies). + internal bool UsesPinnedSdk + { + get; + private set; + } = true; + + /// The root of this run's temporary directories, outside any repository. + internal string Root => _temporary?.Path ?? string.Empty; + + /// The isolated NuGet global packages folder. + internal string PackageCache + { + get; + private set; + } = string.Empty; + + /// The NuGet.Config every restore uses (exclusively, through --configfile). + internal string NuGetConfiguration + { + get; + private set; + } = string.Empty; + + /// The environment every child dotnet command receives. + internal IReadOnlyDictionary Environment => _environment; + + /// The isolated consumer project directory. + internal string ConsumerDirectory + { + get; + private set; + } = string.Empty; + + /// The build output of the isolated consumer. + internal string ConsumerOutput => Path.Combine(ConsumerDirectory, "bin", "Release", "net10.0"); + + /// The CheatEnginePluginOutputPath deployment folder of the isolated consumer. + internal string DeploymentDirectory + { + get; + private set; + } = string.Empty; + + /// The template home (isolated CLI and template engine settings). + internal string TemplateHome + { + get; + private set; + } = string.Empty; + + /// The directory of the instantiated template. + internal string TemplateDirectory + { + get; + private set; + } = string.Empty; + + /// The build output of the instantiated template. + internal string TemplateOutput => Path.Combine(TemplateDirectory, "bin", "Release", "net10.0"); + + /// Whether every consumer step succeeded. + internal bool ConsumerSucceeded + { + get; + private set; + } + + /// Whether every template step succeeded. + internal bool TemplateSucceeded + { + get; + private set; + } + + /// The reviewed identity of the pinned SDK (the hardcoded 1.0.0 reference values above). + internal JsonElement ConsumedSdk + { + get + { + Assert.True(_hasConsumedSdkIdentity, "The reviewed SDK identity applies only when the consumers use the pinned SDK."); + return PinnedSdkIdentity.RootElement; + } + } + + /// + public async ValueTask InitializeAsync() + { + PackageSourceDecision decision = PackageSourceResolution.ResolveFromEnvironment(); + SourceKind = decision.Kind; + if (decision.Kind == PackageSourceKind.Invalid) + { + SetupFailure = decision.Error; + return; + } + + _temporary = new TemporaryDirectory("PackageConsumptionSmoke"); + _environment = CreateEnvironment(Root); + PackageCache = _environment["NUGET_PACKAGES"]; + PackageSource = decision.Kind == PackageSourceKind.ConfiguredDirectory + ? decision.Directory! + : await PackRepositoryAsync(_temporary.CreateDirectory("packages")); + if (SetupFailure is not null) + { + return; + } + + Archives = PackageArchive.ReadDirectory(PackageSource); + PackageArchive? client = Archives.FirstOrDefault(static archive => !archive.IsSymbolPackage && archive.Id == ClientPackageId); + if (client is null) + { + SetupFailure = $"The package directory '{PackageSource}' holds no {ClientPackageId} package."; + return; + } + + ClientVersion = client.Version; + string? sdkSource = ResolveSdkVersion(); + NuGetConfiguration = WriteNuGetConfiguration(Path.Combine(Root, "NuGet.Config"), PackageCache, PackageSource, sdkSource); + + await BuildConsumerAsync(); + await InstantiateTemplateAsync(); + } + + /// + public ValueTask DisposeAsync() + { + _temporary?.Dispose(); + return ValueTask.CompletedTask; + } + + /// Fails the calling test when the package directory could not be used. + internal void RequirePackages() + { + Assert.True(SetupFailure is null, SetupFailure); + } + + /// Fails the calling test unless the isolated consumer restored and built. + internal void RequireConsumer() + { + RequirePackages(); + Assert.True(ConsumerSucceeded, $"The isolated package consumer did not build.{System.Environment.NewLine}{DescribeSteps("consumer")}"); + } + + /// Fails the calling test unless the template installed, instantiated, restored and built. + internal void RequireTemplate() + { + RequirePackages(); + Assert.True(TemplateSucceeded, $"The template did not install, instantiate or build.{System.Environment.NewLine}{DescribeSteps("template")}"); + } + + /// The one package (not symbol package) with this id. + internal PackageArchive Package(string id) + { + RequirePackages(); + return Assert.Single(Archives, archive => !archive.IsSymbolPackage && archive.Id == id); + } + + /// Creates a new directory under . + internal string CreateDirectory(string name) + { + RequirePackages(); + return _temporary!.CreateDirectory(name); + } + + /// Runs dotnet with the isolated environment. + internal Task RunAsync(string workingDirectory, params string[] arguments) + { + return DotNetProcess.RunAsync(workingDirectory, _environment, arguments); + } + + /// Runs dotnet with the isolated environment and extra variables. + internal Task RunAsync(string workingDirectory, IReadOnlyDictionary overrides, + params string[] arguments) + { + Dictionary environment = new(_environment, StringComparer.Ordinal); + foreach ((string name, string value) in overrides) + { + environment[name] = value; + } + + return DotNetProcess.RunAsync(workingDirectory, environment, arguments); + } + + /// A consumer project file that references the packed Client and, optionally, CheatEngine.SDK directly. + internal static string CreateConsumerProject(string clientVersion, string? sdkVersion, string extraProperties = "") + { + string sdkPackageReference = sdkVersion is null + ? string.Empty + : $" "; + + return $$""" + + + net10.0 + 14.0 + enable + enable + x64 + true + false + true + obj/Generated + {{extraProperties}} + + + + {{sdkPackageReference}} + + + """; + } + + /// + /// Writes a NuGet.Config whose package source mapping sends the Client ids to the local feed only, and CheatEngine.SDK + /// to when one is given, otherwise to nuget.org. The exact-id pattern has the highest + /// precedence (https://learn.microsoft.com/nuget/consume-packages/package-source-mapping#package-pattern-precedence). + /// A packageSourceMapping section may only contain packageSource elements, and restores pass this file with + /// --configfile, so no other configuration applies. + /// + internal static string WriteNuGetConfiguration(string path, string packagesFolder, string clientSource, string? sdkSource) + { + string sdkSourceLine = sdkSource is null ? string.Empty : $""" """; + string sdkMapping = sdkSource is null + ? string.Empty + : $""" {"\r\n"} {"\r\n"} """; + string nuGetOrgSdkPattern = sdkSource is null ? $""" """ : string.Empty; + string configuration = $""" + + + + + + + + + {sdkSourceLine} + + + + + + + + {sdkMapping} + + {nuGetOrgSdkPattern} + + + + + """; + File.WriteAllText(path, configuration, new UTF8Encoding(false)); + return path; + } + + /// Fails when a directory or one of its ancestors holds MSBuild or solution files that a restore would pick up. + internal static void AssertOutsideAnyRepository(string directory) + { + string[] markers = ["Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "CheatEngine.Client.slnx"]; + for (DirectoryInfo? current = new DirectoryInfo(directory).Parent; current is not null; current = current.Parent) + { + foreach (string marker in markers) + { + Assert.False(File.Exists(Path.Combine(current.FullName, marker)), + $"'{directory}' has '{Path.Combine(current.FullName, marker)}' above it, so its build would not be isolated from a workspace."); + } + } + } + + /// + /// Copies the pinned CheatEngine.SDK package of the isolated cache into under another version, + /// for tests that need an SDK the Client was not built for. The repository signature covers the original content, so + /// the modified copy drops .signature.p7s; a modified package that keeps it fails with NU3008 + /// (https://learn.microsoft.com/nuget/consume-packages/installing-signed-packages). + /// + internal string CreateReversionedSdkPackage(string feed, string version) + { + RequireConsumer(); + Assert.True(UsesPinnedSdk, $"Re-versioning needs the pinned SDK from nuget.org; unset {SdkPackageSourceVariable}."); + string pinned = SdkVersion.ToLowerInvariant(); + string source = Path.Combine(PackageCache, "cheatengine.sdk", pinned, $"cheatengine.sdk.{pinned}.nupkg"); + string destination = Path.Combine(feed, $"{SdkPackageId}.{version}.nupkg"); + File.Copy(source, destination, overwrite: true); + using ZipArchive archive = ZipFile.Open(destination, ZipArchiveMode.Update); + archive.GetEntry(".signature.p7s")?.Delete(); + ZipArchiveEntry nuspec = archive.Entries.Single(static entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + string text; + using (StreamReader reader = new(nuspec.Open())) + { + text = reader.ReadToEnd(); + } + + string name = nuspec.FullName; + nuspec.Delete(); + ZipArchiveEntry replacement = archive.CreateEntry(name); + using StreamWriter writer = new(replacement.Open(), new UTF8Encoding(false)); + writer.Write(text.Replace($"{SdkVersion}", $"{version}", StringComparison.Ordinal)); + return destination; + } + + /// Writes a structured evidence line into the test output, which the TRX report keeps. + internal static void Evidence(string fact, string text) + { + TestContext.Current.TestOutputHelper?.WriteLine($"evidence[{fact}] {text}"); + } + + private static Dictionary CreateEnvironment(string root) + { + // NUGET_PACKAGES overrides a globalPackagesFolder setting and child processes inherit the parent's value, so it is + // set explicitly for every command; package source mapping is ignored for packages already in the global folder. + // A fresh DOTNET_CLI_HOME triggers the CLI first-run experience, whose side effects are disabled here. + return new Dictionary(StringComparer.Ordinal) + { + ["NUGET_PACKAGES"] = Path.Combine(root, "packages-cache"), + ["NUGET_HTTP_CACHE_PATH"] = Path.Combine(root, "http-cache"), + ["DOTNET_CLI_HOME"] = Path.Combine(root, "cli-home"), + ["DOTNET_NEW_HOME"] = Path.Combine(root, "template-engine"), + ["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "false", + ["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "false", + ["DOTNET_NOLOGO"] = "true", + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "true", + ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "false", + ["MSBUILDDISABLENODEREUSE"] = "1" + }; + } + + private static string EscapeXml(string value) + { + return SecurityElement.Escape(value) ?? throw new InvalidOperationException("Could not escape the NuGet configuration value."); + } + + private string? ResolveSdkVersion() + { + string? sdkSource = System.Environment.GetEnvironmentVariable(SdkPackageSourceVariable); + if (!string.IsNullOrWhiteSpace(sdkSource)) + { + string[] packages = Directory.GetFiles(sdkSource, "*.nupkg"); + Assert.True(packages.Length == 1, $"{SdkPackageSourceVariable} must name a directory with exactly one {SdkPackageId} package."); + PackageArchive sdk = PackageArchive.Read(packages[0]); + Assert.Equal(SdkPackageId, sdk.Id); + SdkVersion = sdk.Version; + UsesPinnedSdk = false; + return Path.GetFullPath(sdkSource); + } + + XDocument pin = XDocument.Load(RepositoryLayout.Combine("eng/CheatEngineSdk.props")); + SdkVersion = Assert.Single(pin.Descendants("CheatEngineSdkVersion")).Value.Trim(); + Assert.Equal(PinnedSdkIdentity.RootElement.GetProperty("version").GetString(), SdkVersion); + _hasConsumedSdkIdentity = true; + return null; + } + + private async Task PackRepositoryAsync(string output) + { + DotNetProcessResult pack = await DotNetProcess.RunAsync(RepositoryLayout.Root, + "pack", RepositoryLayout.Combine("CheatEngine.Client.slnx"), "--configuration", "Release", "--output", output, + "-nodeReuse:false"); + _steps.Add(new SmokeStep("self-pack", pack)); + if (pack.ExitCode != 0) + { + SetupFailure = $"Packing the repository for the local run failed.{System.Environment.NewLine}{pack}"; + } + + return output; + } + + private async Task StepAsync(string name, string workingDirectory, params string[] arguments) + { + DotNetProcessResult result = await RunAsync(workingDirectory, arguments); + _steps.Add(new SmokeStep(name, result)); + return result.ExitCode == 0; + } + + private async Task BuildConsumerAsync() + { + ConsumerDirectory = _temporary!.CreateDirectory("package-consumer"); + DeploymentDirectory = _temporary.CreateDirectory("deployment"); + string project = Path.Combine(ConsumerDirectory, $"{ConsumerName}.csproj"); + await File.WriteAllTextAsync(project, CreateConsumerProject(ClientVersion, SdkVersion), new UTF8Encoding(false)); + await File.WriteAllTextAsync(Path.Combine(ConsumerDirectory, "Plugin.cs"), ConsumerSource, new UTF8Encoding(false)); + + string[] build = + [ + "build", project, "--configuration", "Release", "--no-restore", "-p:UseSharedCompilation=false", + $"-p:CheatEnginePluginOutputPath={DeploymentDirectory}" + ]; + ConsumerSucceeded = await StepAsync("consumer restore", ConsumerDirectory, + "restore", project, "--configfile", NuGetConfiguration, "--packages", PackageCache) + && await StepAsync("consumer build", ConsumerDirectory, build) + // The second build proves that deployment replaces files already in place. + && await StepAsync("consumer rebuild", ConsumerDirectory, build); + } + + private async Task InstantiateTemplateAsync() + { + TemplateHome = _temporary!.CreateDirectory("template-home"); + TemplateDirectory = Path.Combine(TemplateHome, ConsumerName); + string templatePackage = Package(TemplatePackageId).Path; + string project = Path.Combine(TemplateDirectory, $"{ConsumerName}.csproj"); + TemplateSucceeded = await StepAsync("template install", TemplateHome, "new", "install", templatePackage, "--force") + && await StepAsync("template dry run", TemplateHome, + "new", "ceplugin", "--dry-run", "--name", ConsumerName, "--output", Path.Combine(TemplateHome, "dry-run")) + && await StepAsync("template instantiate", TemplateHome, + "new", "ceplugin", "--name", ConsumerName, "--output", TemplateDirectory) + && await StepAsync("template restore", TemplateDirectory, + "restore", project, "--configfile", NuGetConfiguration, "--packages", PackageCache) + && await StepAsync("template build", TemplateDirectory, + "build", project, "--configuration", "Release", "--no-restore", "-p:UseSharedCompilation=false"); + } + + private string DescribeSteps(string prefix) + { + StringBuilder description = new(); + foreach (SmokeStep step in _steps) + { + if (step.Name.StartsWith(prefix, StringComparison.Ordinal) || !step.Succeeded) + { + description.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $"[{step.Name}] exit {step.Result.ExitCode}"); + if (!step.Succeeded) + { + description.AppendLine(step.Result.ToString()); + } + } + } + + return description.ToString(); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/ReadmeCodeBlocks.cs b/tests/CheatEngine.Client.Tests/Packaging/ReadmeCodeBlocks.cs new file mode 100644 index 0000000..c7394ea --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/ReadmeCodeBlocks.cs @@ -0,0 +1,127 @@ +using System.Text.RegularExpressions; + +namespace CheatEngine.Client.Tests.Packaging; + +/// One fenced C# block of a README. +/// The 1-based line of the opening fence. +/// +/// Whether the block is marked csharp nocompile, with a written reason next to it. +/// +/// The code between the fences. +internal sealed record ReadmeCodeBlock(int Line, bool NoCompile, string Code); + +/// The C# blocks of a README and every rule it breaks. +internal sealed record ReadmeCodeBlockSet(IReadOnlyList Blocks, IReadOnlyList Problems); + +/// +/// Reads the fenced C# code blocks of a Markdown file, as CommonMark delimits them: a fence of at least three +/// backticks or tildes, indented by at most three spaces, closed by a fence of the same character that is at least +/// as long. A C# block is labelled csharp, so that compiles it. +/// A block that deliberately does not compile is labelled csharp nocompile, and the line right above its +/// opening fence gives the reason: <!-- nocompile: the reason -->. +/// +internal static partial class ReadmeCodeBlocks +{ + /// The info-string word that exempts a C# block from compilation. + internal const string NoCompileWord = "nocompile"; + + private const string CSharpLanguage = "csharp"; + private const int RegexTimeoutMilliseconds = 1000; + + /// The labels of a C# block that the compilation test would not see. + private static readonly string[] OtherCSharpLabels = ["cs", "c#", "c-sharp"]; + + /// Parses . + internal static ReadmeCodeBlockSet Parse(string markdown) + { + ArgumentNullException.ThrowIfNull(markdown); + string[] lines = markdown.ReplaceLineEndings("\n").Split('\n'); + List blocks = []; + List problems = []; + int index = 0; + while (index < lines.Length) + { + Match opening = OpeningFence().Match(lines[index]); + if (!opening.Success) + { + index++; + continue; + } + + string fence = opening.Groups["fence"].Value; + string[] info = opening.Groups["info"].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + int closing = FindClosingFence(lines, index + 1, fence); + int line = index + 1; + if (closing < 0) + { + problems.Add($"line {line}: the code block opened here is never closed."); + break; + } + + string language = info.Length > 0 ? info[0] : string.Empty; + if (OtherCSharpLabels.Contains(language, StringComparer.OrdinalIgnoreCase)) + { + problems.Add( + $"line {line}: label a C# block '{CSharpLanguage}', not '{language}', so that it is compiled."); + } + else if (string.Equals(language, CSharpLanguage, StringComparison.OrdinalIgnoreCase)) + { + bool noCompile = false; + foreach (string word in info.Skip(1)) + { + if (string.Equals(word, NoCompileWord, StringComparison.Ordinal)) + { + noCompile = true; + } + else + { + problems.Add( + $"line {line}: unknown code block option '{word}' (only '{NoCompileWord}' exists)."); + } + } + + if (noCompile && !HasNoCompileReason(lines, index)) + { + problems.Add($"line {line}: a '{CSharpLanguage} {NoCompileWord}' block needs its reason on the " + + $"line right above it: ."); + } + + blocks.Add(new ReadmeCodeBlock(line, noCompile, string.Join('\n', lines[(index + 1)..closing]))); + } + + index = closing + 1; + } + + return new ReadmeCodeBlockSet(blocks, problems); + } + + private static int FindClosingFence(string[] lines, int start, string fence) + { + for (int index = start; index < lines.Length; index++) + { + Match closing = ClosingFence().Match(lines[index]); + if (closing.Success && closing.Groups["fence"].Value[0] == fence[0] && + closing.Groups["fence"].Value.Length >= fence.Length) + { + return index; + } + } + + return -1; + } + + private static bool HasNoCompileReason(string[] lines, int fenceIndex) + { + return fenceIndex > 0 && NoCompileReason().IsMatch(lines[fenceIndex - 1]); + } + + [GeneratedRegex(@"^ {0,3}(?`{3,}|~{3,})[ \t]*(?[^`]*?)[ \t]*$", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex OpeningFence(); + + [GeneratedRegex(@"^ {0,3}(?`{3,}|~{3,})[ \t]*$", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex ClosingFence(); + + [GeneratedRegex(@"^\s*\s*$", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex NoCompileReason(); +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/ReadmeCodeBlocksTests.cs b/tests/CheatEngine.Client.Tests/Packaging/ReadmeCodeBlocksTests.cs new file mode 100644 index 0000000..bb2b412 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/ReadmeCodeBlocksTests.cs @@ -0,0 +1,119 @@ +namespace CheatEngine.Client.Tests.Packaging; + +/// +/// The code block rules that applies to the packed READMEs, proven on +/// fixed Markdown so that both CI legs check them without packages. +/// +public sealed class ReadmeCodeBlocksTests +{ + [Fact] + public void ACSharpBlockIsCompiledAndOtherLanguagesAreIgnored() + { + const string markdown = """ + # Title + + ```powershell + dotnet build + ``` + + ```csharp + namespace Sample; + ``` + + ```xml + + ``` + """; + + ReadmeCodeBlockSet parsed = ReadmeCodeBlocks.Parse(markdown); + + Assert.Empty(parsed.Problems); + ReadmeCodeBlock block = Assert.Single(parsed.Blocks); + Assert.Equal(7, block.Line); + Assert.False(block.NoCompile); + Assert.Equal("namespace Sample;", block.Code); + } + + [Fact] + public void ANoCompileBlockNeedsItsReasonRightAboveIt() + { + const string markdown = """ + + ```csharp nocompile + client.Memory.At(address).Write(1); + ``` + + ```csharp nocompile + client.Memory.At(address).Write(2); + ``` + + + ```csharp nocompile + client.Memory.At(address).Write(3); + ``` + + + + ```csharp nocompile + client.Memory.At(address).Write(4); + ``` + """; + + ReadmeCodeBlockSet parsed = ReadmeCodeBlocks.Parse(markdown); + + Assert.Equal([true, true, true, true], parsed.Blocks.Select(static block => block.NoCompile)); + Assert.Equal(3, parsed.Problems.Count); + Assert.StartsWith("line 6:", parsed.Problems[0], StringComparison.Ordinal); + Assert.StartsWith("line 11:", parsed.Problems[1], StringComparison.Ordinal); + Assert.StartsWith("line 17:", parsed.Problems[2], StringComparison.Ordinal); + } + + [Theory] + [InlineData("cs")] + [InlineData("C#")] + [InlineData("c-sharp")] + public void ACSharpBlockUnderAnotherLabelIsRefused(string label) + { + string markdown = $"```{label}\nnamespace Sample;\n```\n"; + + ReadmeCodeBlockSet parsed = ReadmeCodeBlocks.Parse(markdown); + + Assert.Empty(parsed.Blocks); + Assert.Contains($"not '{label}'", Assert.Single(parsed.Problems), StringComparison.Ordinal); + } + + [Fact] + public void AnUnknownOptionAndAnUnclosedBlockAreRefused() + { + ReadmeCodeBlockSet unknown = ReadmeCodeBlocks.Parse("```csharp skip\nnamespace Sample;\n```\n"); + ReadmeCodeBlockSet unclosed = ReadmeCodeBlocks.Parse("Text\n```csharp\nnamespace Sample;\n"); + + Assert.Contains("unknown code block option 'skip'", Assert.Single(unknown.Problems), StringComparison.Ordinal); + Assert.Empty(unclosed.Blocks); + Assert.Equal("line 2: the code block opened here is never closed.", Assert.Single(unclosed.Problems)); + } + + [Fact] + public void AFenceClosesOnlyWithTheSameCharacterAndAtLeastItsLength() + { + const string markdown = """ + ````markdown + ```csharp + not a block of its own + ``` + ```` + + ~~~csharp + namespace Sample; + ``` + ~~~~ + """; + + ReadmeCodeBlockSet parsed = ReadmeCodeBlocks.Parse(markdown); + + Assert.Empty(parsed.Problems); + ReadmeCodeBlock block = Assert.Single(parsed.Blocks); + Assert.Equal(7, block.Line); + Assert.Equal("namespace Sample;\n```", block.Code); + } +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/ReadmeSnippetCompilationTests.cs b/tests/CheatEngine.Client.Tests/Packaging/ReadmeSnippetCompilationTests.cs new file mode 100644 index 0000000..73555fd --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/ReadmeSnippetCompilationTests.cs @@ -0,0 +1,229 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.Packaging; + +/// +/// Every csharp block of the README that each packed package publishes on nuget.org, and of the repository +/// README, compiles against the packed Client. The blocks of one README form one plugin project, as a plugin author +/// would copy them: the documented references (CheatEngine.Client, CheatEngine.SDK and +/// Microsoft.Extensions.Configuration.Json), the template's Nullable and ImplicitUsings settings, +/// and warnings as errors. A README that declares a [CheatEnginePlugin] type also runs the Hosting plugin +/// profile checks. A csharp nocompile block is skipped only with its written reason +/// (). Every PackageReference a README writes names the version this +/// project compiles against: the SDK pin, the Microsoft.Extensions.Configuration.Json version the packed template +/// stamps, and the X.Y.Z placeholder for CheatEngine.Client, which the reader replaces. +/// +[Collection(PackageConsumptionSmokeSerialGroup.Name)] +[Trait("Category", "PackageConsumption")] +public sealed partial class ReadmeSnippetCompilationTests(PackagedClientFeedFixture fixture) +{ + /// The theory value that names the repository README instead of a package. + private const string RepositoryReadme = "README.md"; + + private const string TemplateProjectEntry = "content/CheatEngine.Plugin/CheatEngine.Plugin.csproj"; + private const string ConfigurationJsonPackageId = "Microsoft.Extensions.Configuration.Json"; + private const string PluginAttribute = "[CheatEnginePlugin("; + + /// The placeholder a README writes for the CheatEngine.Client version the reader installs. + private const string ClientVersionPlaceholder = "X.Y.Z"; + + private const int RegexTimeoutMilliseconds = 1000; + + /// Every packed package and the repository README. + public static TheoryData Readmes => [.. PackagedClientFeedFixture.PackageIds, RepositoryReadme]; + + [Theory] + [MemberData(nameof(Readmes))] + public async Task EveryCSharpBlockCompilesAgainstThePackedClientAsync(string readme) + { + fixture.RequirePackages(); + ReadmeCodeBlockSet parsed = ReadmeCodeBlocks.Parse(ReadReadme(readme)); + Assert.True(parsed.Problems.Count == 0, + $"The {readme} README breaks the code block rules:{Environment.NewLine}" + + string.Join(Environment.NewLine, parsed.Problems)); + + ReadmeCodeBlock[] compiled = [.. parsed.Blocks.Where(static block => !block.NoCompile)]; + int[] skipped = [.. parsed.Blocks.Where(static block => block.NoCompile).Select(static block => block.Line)]; + PackagedClientFeedFixture.Evidence(nameof(EveryCSharpBlockCompilesAgainstThePackedClientAsync), + $"readme={readme} compiled={string.Join(',', compiled.Select(static block => block.Line))} " + + $"nocompile={string.Join(',', skipped)}"); + if (compiled.Length == 0) + { + return; + } + + string directory = fixture.CreateDirectory($"readme-snippets-{readme.Replace('.', '-')}"); + string project = Path.Combine(directory, "Readme.Snippets.csproj"); + bool declaresPlugin = + compiled.Any(static block => block.Code.Contains(PluginAttribute, StringComparison.Ordinal)); + await File.WriteAllTextAsync(project, CreateProject(declaresPlugin), new UTF8Encoding(false), + TestContext.Current.CancellationToken); + foreach (ReadmeCodeBlock block in compiled) + { + // The file name carries the README line of the block, so a compiler error points to the snippet. + string file = Path.Combine(directory, $"README.L{block.Line:0000}.cs"); + await File.WriteAllTextAsync(file, block.Code, new UTF8Encoding(false), + TestContext.Current.CancellationToken); + } + + DotNetProcessResult restore = await fixture.RunAsync(directory, "restore", project, "--configfile", + fixture.NuGetConfiguration, "--packages", fixture.PackageCache); + DotNetProcessResult build = await fixture.RunAsync(directory, "build", project, "--configuration", "Release", + "--no-restore", "-p:UseSharedCompilation=false"); + + Assert.True(restore.ExitCode == 0, restore.ToString()); + Assert.True(build.ExitCode == 0, + $"A C# block of the {readme} README does not compile:{Environment.NewLine}{build}"); + } + + [Fact] + public void TheUmbrellaReadmeShowsACompiledPlugin() + { + fixture.RequirePackages(); + ReadmeCodeBlockSet parsed = ReadmeCodeBlocks.Parse(ReadReadme(PackagedClientFeedFixture.ClientPackageId)); + + Assert.Contains(parsed.Blocks, + static block => !block.NoCompile && block.Code.Contains(PluginAttribute, StringComparison.Ordinal)); + } + + [Theory] + [MemberData(nameof(Readmes))] + public void EveryDocumentedPackageReferenceNamesTheCompiledVersion(string readme) + { + fixture.RequirePackages(); + Dictionary expected = ExpectedReferenceVersions(); + List references = DocumentedReferences(ReadReadme(readme)); + PackagedClientFeedFixture.Evidence(nameof(EveryDocumentedPackageReferenceNamesTheCompiledVersion), + $"readme={readme} references=" + + string.Join(',', references.Select(static reference => $"{reference.Id}@{reference.Version}"))); + List offenders = []; + foreach (DocumentedReference reference in references) + { + if (!expected.TryGetValue(reference.Id, out string? version)) + { + offenders.Add($"line {reference.Line}: {reference.Id} is not a reference of the documented plugin " + + $"project ({string.Join(", ", expected.Keys)})"); + } + else if (!string.Equals(reference.Version, version, StringComparison.Ordinal)) + { + offenders.Add($"line {reference.Line}: {reference.Id} Version=\"{reference.Version ?? ""}\", " + + $"expected \"{version}\""); + } + } + + Assert.True(offenders.Count == 0, + $"A PackageReference of the {readme} README does not name the version its snippets compile against:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void TheUmbrellaReadmeDocumentsEveryPluginProjectReference() + { + fixture.RequirePackages(); + string[] documented = + [ + .. DocumentedReferences(ReadReadme(PackagedClientFeedFixture.ClientPackageId)) + .Select(static reference => reference.Id).Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.Ordinal) + ]; + + Assert.Equal(ExpectedReferenceVersions().Keys.Order(StringComparer.Ordinal), documented); + } + + /// The PackageReference elements that a README writes, with their 1-based lines. + private static List DocumentedReferences(string markdown) + { + List references = []; + foreach (Match element in PackageReferenceElement().Matches(markdown)) + { + Dictionary attributes = new(StringComparer.Ordinal); + foreach (Match attribute in XmlAttribute().Matches(element.Groups["attributes"].Value)) + { + attributes[attribute.Groups["name"].Value] = attribute.Groups["value"].Value; + } + + int line = markdown.AsSpan(0, element.Index).Count('\n') + 1; + references.Add(new DocumentedReference(attributes.GetValueOrDefault("Include", string.Empty), + attributes.GetValueOrDefault("Version"), line)); + } + + return references; + } + + /// The version each reference of the documented plugin project must name, by package id. + private Dictionary ExpectedReferenceVersions() + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [PackagedClientFeedFixture.ClientPackageId] = ClientVersionPlaceholder, + [PackagedClientFeedFixture.SdkPackageId] = fixture.SdkVersion, + [ConfigurationJsonPackageId] = PackedTemplateVersion(ConfigurationJsonPackageId) + }; + } + + /// The version that the packed template's project gives to . + private string PackedTemplateVersion(string packageId) + { + XDocument template = XDocument.Parse( + fixture.Package(PackagedClientFeedFixture.TemplatePackageId).EntryText(TemplateProjectEntry)); + return (string?) template.Descendants("PackageReference") + .Single(reference => (string?) reference.Attribute("Include") == packageId) + .Attribute("Version") + ?? throw new InvalidOperationException($"The packed template does not version {packageId}."); + } + + private string ReadReadme(string readme) + { + return readme == RepositoryReadme + ? File.ReadAllText(RepositoryLayout.Combine(RepositoryReadme)) + : fixture.Package(readme).EntryText("README.md"); + } + + /// The plugin project the READMEs document, with the packed versions. + private string CreateProject(bool declaresPlugin) + { + // The template package stamps the Microsoft.Extensions.Configuration.Json version the Client documents. + string configurationJson = PackedTemplateVersion(ConfigurationJsonPackageId); + string pluginProject = declaresPlugin ? "true" : "false"; + const string clientId = PackagedClientFeedFixture.ClientPackageId; + const string sdkId = PackagedClientFeedFixture.SdkPackageId; + return $""" + + + net10.0 + 14.0 + enable + enable + x64 + true + {pluginProject} + false + + + + + + + + """; + } + + /// A PackageReference start or empty element, whatever the order of its attributes. + [GeneratedRegex(@"[^>]*)>", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex PackageReferenceElement(); + + [GeneratedRegex(@"\b(?[A-Za-z]+)\s*=\s*""(?[^""]*)""", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex XmlAttribute(); + + /// One PackageReference that a README writes. + /// Its Include attribute. + /// Its Version attribute, or without one. + /// The 1-based README line of the element. + private sealed record DocumentedReference(string Id, string? Version, int Line); +} diff --git a/tests/CheatEngine.Client.Tests/Packaging/TemplateInstantiationTests.cs b/tests/CheatEngine.Client.Tests/Packaging/TemplateInstantiationTests.cs new file mode 100644 index 0000000..9487370 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/Packaging/TemplateInstantiationTests.cs @@ -0,0 +1,236 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.Packaging; + +/// +/// Proves what dotnet new ceplugin generates from the packed template, beyond the smoke build of +/// : names derived from the project name, a lock file restore, +/// --no-restore, the name folder, a packed .gitignore, and a build under this repository's code style +/// with warnings as errors (plan L22). Every instance lives in the fixture's temporary directory, outside any +/// repository, and uses the fixture's template installation, NuGet configuration and package folder. +/// +[Collection(PackageConsumptionSmokeSerialGroup.Name)] +[Trait("Category", "PackageConsumption")] +[Trait("Qualification", "Q40")] +public sealed partial class TemplateInstantiationTests(PackagedClientFeedFixture fixture) +{ + private const string ContentFolder = "content/CheatEngine.Plugin/"; + private const string LockFileName = "packages.lock.json"; + private const int RegexTimeoutMilliseconds = 1000; + + [Fact] + public void PackedTemplateCarriesItsGitIgnoreAndNoLockFile() + { + PackageArchive template = fixture.Package(PackagedClientFeedFixture.TemplatePackageId); + string[] rules = template.EntryText(ContentFolder + ".gitignore") + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static line => !line.StartsWith('#')) + .ToArray(); + XDocument project = XDocument.Parse(template.EntryText(ContentFolder + "CheatEngine.Plugin.csproj")); + + Assert.Contains("bin/", rules); + Assert.Contains("obj/", rules); + Assert.DoesNotContain(rules, static rule => rule.Contains(".json", StringComparison.OrdinalIgnoreCase)); + // LockFileTests keeps the committed content folder free of a lock file; this checks the package itself, which + // may be packed from a working tree that holds one restored in place. + Assert.DoesNotContain(template.EntryNames, + static entry => entry.EndsWith(LockFileName, StringComparison.Ordinal)); + Assert.Equal("true", Assert.Single(project.Descendants("RestorePackagesWithLockFile")).Value.Trim()); + } + + [Fact] + public void InstantiatedTemplateLockFileRecordsThePinnedSdk() + { + fixture.RequireTemplate(); + string lockPath = Path.Combine(fixture.TemplateDirectory, LockFileName); + using JsonDocument lockFile = JsonDocument.Parse(File.ReadAllText(lockPath)); + JsonElement packages = lockFile.RootElement.GetProperty("dependencies").GetProperty("net10.0"); + JsonElement sdk = packages.GetProperty(PackagedClientFeedFixture.SdkPackageId); + JsonElement client = packages.GetProperty(PackagedClientFeedFixture.ClientPackageId); + // The .NET SDK adds this reference for IsAotCompatible at the version it bundles, so the lock changes with the + // SDK: both template READMEs tell plugin authors to pin the SDK or regenerate the lock after an update. + JsonElement illink = packages.GetProperty("Microsoft.NET.ILLink.Tasks"); + string sdkFolder = Path.Combine(fixture.PackageCache, "cheatengine.sdk", fixture.SdkVersion.ToLowerInvariant()); + using JsonDocument metadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(sdkFolder, ".nupkg.metadata"))); + string contentHash = sdk.GetProperty("contentHash").GetString()!; + + Assert.Equal("Direct", sdk.GetProperty("type").GetString()); + Assert.Equal(fixture.SdkVersion, sdk.GetProperty("resolved").GetString()); + Assert.Equal(metadata.RootElement.GetProperty("contentHash").GetString(), contentHash); + Assert.Equal("Direct", client.GetProperty("type").GetString()); + Assert.Equal(fixture.ClientVersion, client.GetProperty("resolved").GetString()); + Assert.Equal("Direct", illink.GetProperty("type").GetString()); + if (fixture.UsesPinnedSdk) + { + Assert.Equal(fixture.ConsumedSdk.GetProperty("contentHashSha512").GetString(), contentHash); + } + + PackagedClientFeedFixture.Evidence(nameof(InstantiatedTemplateLockFileRecordsThePinnedSdk), + $"sdk={fixture.SdkVersion} lockContentHash={contentHash} client={fixture.ClientVersion} " + + $"illinkTasks={illink.GetProperty("resolved").GetString()}"); + } + + [Fact] + public async Task TemplateDerivesDistinctPluginNamesAndLuaGlobalsFromTheProjectNameAsync() + { + fixture.RequireTemplate(); + (string Project, string DisplayName, string LuaGlobal)[] expected = + [ + (PackagedClientFeedFixture.ConsumerName, "Smoke.Plugin", "smoke_plugin_status"), + ("QualTemplatePlugin", "QualTemplatePlugin", "qual_template_plugin_status"), + ("Contoso.CheatEngine.Plugin", "Contoso.CheatEngine.Plugin", "contoso_cheat_engine_plugin_status"), + ("My-Plugin2", "My-Plugin2", "my_plugin2_status"), + ("1Plugin", "1Plugin", "plugin_1_plugin_status"), + ("Überwachung.Plugin", "_berwachung.Plugin", "berwachung_plugin_status") + ]; + string root = fixture.CreateDirectory("template-names"); + List globals = []; + foreach ((string project, string displayName, string luaGlobal) in expected) + { + string directory = project == PackagedClientFeedFixture.ConsumerName + ? fixture.TemplateDirectory + : await InstantiateAsync(root, project, "--no-restore"); + (string derivedDisplayName, string global) = await ReadDerivedNamesAsync(directory); + + Assert.Equal(displayName, derivedDisplayName); + Assert.Equal(luaGlobal, global); + Assert.Matches(LuaStatusGlobal(), global); + globals.Add(global); + } + + Assert.Equal(globals.Count, globals.Distinct(StringComparer.Ordinal).Count()); + PackagedClientFeedFixture.Evidence( + nameof(TemplateDerivesDistinctPluginNamesAndLuaGlobalsFromTheProjectNameAsync), string.Join(' ', globals)); + } + + /// + /// The derivation is lossy, as both template READMEs state: it lowercases the name and turns each camel-case + /// boundary and each run of other characters, non-ASCII letters included, into _, so different project + /// names can derive the same Lua global, which the Client then lets only one plugin register. + /// + [Fact] + public async Task DifferentProjectNamesCanDeriveTheSameLuaGlobalAsync() + { + fixture.RequireTemplate(); + (string Project, string DisplayName, string LuaGlobal)[] expected = + [ + ("MyPlugin", "MyPlugin", "my_plugin_status"), + ("My.Plugin", "My.Plugin", "my_plugin_status"), + ("Плагин", "Plugin", "plugin_status") + ]; + string root = fixture.CreateDirectory("template-name-collisions"); + foreach ((string project, string displayName, string luaGlobal) in expected) + { + (string derivedDisplayName, string global) = + await ReadDerivedNamesAsync(await InstantiateAsync(root, project, "--no-restore")); + + Assert.Equal(displayName, derivedDisplayName); + Assert.Equal(luaGlobal, global); + } + } + + [Fact] + public async Task TemplateRestoresWithALockFileUnlessNoRestoreIsPassedAsync() + { + fixture.RequireTemplate(); + string root = fixture.CreateDirectory("template-restore"); + + // The restore post action finds the fixture's NuGet.Config above the temporary directory. + string restored = await InstantiateAsync(root, "Restored.Plugin"); + string unrestored = await InstantiateAsync(root, "Unrestored.Plugin", "--no-restore"); + + Assert.True(File.Exists(Path.Combine(restored, LockFileName)), $"{restored} has no {LockFileName}."); + Assert.True(File.Exists(Path.Combine(restored, "obj", "project.assets.json")), $"{restored} was not restored."); + Assert.False(File.Exists(Path.Combine(unrestored, LockFileName)), $"--no-restore wrote {LockFileName}."); + Assert.False(Directory.Exists(Path.Combine(unrestored, "obj")), "--no-restore restored the project."); + } + + [Fact] + public async Task TemplateNameWithoutOutputCreatesTheNameFolderAsync() + { + fixture.RequireTemplate(); + string root = fixture.CreateDirectory("template-name-folder"); + + DotNetProcessResult created = + await fixture.RunAsync(root, "new", "ceplugin", "--name", "Named.Plugin", "--no-restore"); + + Assert.True(created.ExitCode == 0, created.ToString()); + Assert.True(File.Exists(Path.Combine(root, "Named.Plugin", "Named.Plugin.csproj")), created.ToString()); + Assert.True(File.Exists(Path.Combine(root, "Named.Plugin", ".gitignore")), created.ToString()); + Assert.Empty(Directory.GetFiles(root)); + } + + [Fact] + public async Task InstantiatedTemplateBuildsWithWarningsAsErrorsUnderTheRepositoryCodeStyleAsync() + { + fixture.RequireTemplate(); + string directory = + await InstantiateAsync(fixture.CreateDirectory("template-strict"), "Strict.Plugin", "--no-restore"); + string project = Path.Combine(directory, "Strict.Plugin.csproj"); + File.Copy(RepositoryLayout.Combine(".editorconfig"), Path.Combine(directory, ".editorconfig")); + string analysisLevel = Assert.Single(XDocument.Load(RepositoryLayout.Combine("Directory.Build.props")) + .Descendants("_CheatEngineClientPinnedAnalysisLevel")).Value.Trim(); + string[] strictBuild = + [ + "build", project, "--configuration", "Release", "--no-restore", "-warnaserror", + "-p:EnforceCodeStyleInBuild=true", $"-p:AnalysisLevel={analysisLevel}", "-p:UseSharedCompilation=false" + ]; + + DotNetProcessResult restore = await fixture.RunAsync(directory, "restore", project, "--configfile", + fixture.NuGetConfiguration, "--packages", fixture.PackageCache); + DotNetProcessResult strict = await fixture.RunAsync(directory, strictBuild); + + // The same build refuses a file that breaks the repository's namespace and indentation rules, so the style + // settings above are in effect rather than silently ignored. + await File.WriteAllTextAsync(Path.Combine(directory, "StyleProbe.cs"), + "namespace Strict.Plugin.StyleProbe\r\n{\r\n internal static class Probe\r\n {\r\n }\r\n}\r\n", + new UTF8Encoding(false), TestContext.Current.CancellationToken); + DotNetProcessResult refused = await fixture.RunAsync(directory, strictBuild); + + Assert.True(restore.ExitCode == 0, restore.ToString()); + Assert.True(strict.ExitCode == 0, strict.ToString()); + Assert.True(refused.ExitCode != 0, refused.ToString()); + Assert.Contains("error IDE0161", refused.StandardOutput, StringComparison.Ordinal); + PackagedClientFeedFixture.Evidence( + nameof(InstantiatedTemplateBuildsWithWarningsAsErrorsUnderTheRepositoryCodeStyleAsync), + $"analysisLevel={analysisLevel} strict=exit {strict.ExitCode} styleViolation=exit {refused.ExitCode}"); + } + + /// Instantiates the installed template as in its folder under a root. + private async Task InstantiateAsync(string root, string name, params string[] options) + { + string directory = Path.Combine(root, name); + string[] arguments = ["new", "ceplugin", "--name", name, "--output", directory, .. options]; + DotNetProcessResult created = await fixture.RunAsync(root, arguments); + Assert.True(created.ExitCode == 0, created.ToString()); + Assert.True(File.Exists(Path.Combine(directory, name + ".csproj")), created.ToString()); + return directory; + } + + /// Reads the plugin name and the Lua status global that an instance's sources declare. + private static async Task<(string DisplayName, string LuaGlobal)> ReadDerivedNamesAsync(string directory) + { + string pluginSource = await File.ReadAllTextAsync(Path.Combine(directory, "Plugin.cs"), + TestContext.Current.CancellationToken); + string luaSource = await File.ReadAllTextAsync(Path.Combine(directory, "Modules", "PluginLuaFunctions.cs"), + TestContext.Current.CancellationToken); + return (Assert.Single(PluginDisplayName().Matches(pluginSource)).Groups["name"].Value, + Assert.Single(LuaFunctionName().Matches(luaSource)).Groups["name"].Value); + } + + [GeneratedRegex("""\[CheatEnginePlugin\("(?[^"]*)"\)\]""", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + private static partial Regex PluginDisplayName(); + + [GeneratedRegex("""\[LuaFunction\("(?[^"]*)"\)\]""", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex LuaFunctionName(); + + /// An ASCII Lua name (Lua 5.3, section 3.1) in lower_snake_case that ends with _status. + [GeneratedRegex("^[a-z_][a-z0-9_]*_status$", RegexOptions.CultureInvariant, RegexTimeoutMilliseconds)] + private static partial Regex LuaStatusGlobal(); +} diff --git a/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs b/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs index 6619db0..bdcc92d 100644 --- a/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs +++ b/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs @@ -7,6 +7,7 @@ using CheatEngine.Client.Extensions.DependencyInjection; using CheatEngine.Client.Hosting; using CheatEngine.Client.Memory; +using CheatEngine.Client.SourceGenerators.Lua; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.References; @@ -23,31 +24,9 @@ public sealed class PublicClientSignatureBoundaryTests private const int ClientBoundaryMaximumDepth = 32; private const int ClientBoundaryMaximumNodes = 256; - private static readonly HashSet ApprovedSdkValueTypes = new(StringComparer.Ordinal) - { - "CheatEngine.SDK.Engine.AddressList.MemoryRecordId", - "CheatEngine.SDK.Engine.Enums.FastScanMethod", - "CheatEngine.SDK.Engine.Enums.VariableType", - "CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions", - "CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo", - "CheatEngine.SDK.Engine.Inspection.ModuleInfo", - "CheatEngine.SDK.Engine.Inspection.ModuleName", - "CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo", - "CheatEngine.SDK.Engine.Inspection.SymbolInfo", - "CheatEngine.SDK.Engine.Inspection.SymbolExpression", - "CheatEngine.SDK.Engine.Inspection.TargetProcessId", - "CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", - "CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", - "CheatEngine.SDK.Engine.Runtime.PointerSize", - "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability", - "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId", - "CheatEngine.SDK.Engine.Runtime.TargetAbi", - "CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions", - "CheatEngine.SDK.Engine.Scanning.Aob.AobPattern", - "CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest", - "CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest", - "CheatEngine.SDK.Engine.Values.Address" - }; + // Single source shared with the Lua generator: source-generators/CheatEngine.Client.SourceGenerators.Lua/ApprovedSdkClientTypes.cs. + private static readonly HashSet ApprovedSdkValueTypes = + new(ApprovedSdkClientTypes.Names, StringComparer.Ordinal); private static readonly HashSet ApprovedFrameworkValueTypes = new(StringComparer.Ordinal) { @@ -77,6 +56,7 @@ public sealed class PublicClientSignatureBoundaryTests "System.Collections.Frozen.FrozenSet`1", "System.Collections.Generic.IAsyncEnumerable`1", "System.Collections.Generic.IEnumerable`1", + "System.Collections.Generic.IList`1", "System.Collections.Generic.IReadOnlyCollection`1", "System.Collections.Generic.IReadOnlyDictionary`2", "System.Collections.Generic.IReadOnlyList`1", @@ -111,9 +91,8 @@ public sealed class PublicClientSignatureBoundaryTests "Microsoft.Extensions.Configuration.IConfigurationSection", "Microsoft.Extensions.DependencyInjection.IServiceCollection", "Microsoft.Extensions.DependencyInjection.IServiceScope", - "Microsoft.Extensions.DependencyInjection.ServiceProvider", "Microsoft.Extensions.Logging.ILogger", - "Microsoft.Extensions.Options.ValidateOptionsResult" + "Microsoft.Extensions.Logging.ILoggingBuilder" }; [Fact] @@ -168,6 +147,23 @@ public void RecursiveVerifierRejectsPointersByReferenceAndFunctionPointers() AssertViolation(method.ReturnType, "function pointer"); } + [Fact] + public void PublicSignaturesRejectDisguisedNativePointers() + { + AssertViolation(typeof(nint), "native-sized integer"); + AssertViolation(typeof(nuint), "native-sized integer"); + AssertViolation(typeof(IntPtr[]), "native-sized integer"); + AssertViolation(typeof(Func), "native-sized integer"); + AssertViolation(typeof(object), "unsupported framework type"); + AssertViolation(typeof(void).MakePointerType(), "pointer type"); + + List violations = []; + VerifyType(typeof(int), "int", violations); + VerifyType(typeof(ulong), "ulong", violations); + VerifyType(typeof(Address), "address", violations); + Assert.Empty(violations); + } + [Fact] public void RecursiveVerifierAllowsApprovedSdkValuesInsideSafeContainers() { @@ -232,7 +228,7 @@ private static IEnumerable GetAggregateClientAssemblies() ReflectionAssembly assembly = candidate; string? assemblyName = assembly.GetName().Name; if (assemblyName is null || !assemblyName.StartsWith("CheatEngine.Client", StringComparison.Ordinal) || - !visited.Add(assemblyName)) + !visited.Add(assemblyName)) { continue; } @@ -252,7 +248,7 @@ private static IEnumerable GetAggregateClientAssemblies() private static void VerifyDeclaredMembers(Type publicType, List violations) { const BindingFlags PublicDeclared = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | - BindingFlags.DeclaredOnly; + BindingFlags.DeclaredOnly; if (typeof(Delegate).IsAssignableFrom(publicType)) { MethodInfo? invoke = publicType.GetMethod("Invoke", BindingFlags.Public | BindingFlags.Instance); @@ -333,7 +329,7 @@ private static void VerifyGenericParameterConstraints(IEnumerable genericP foreach (Type genericParameter in genericParameters.Where(static parameter => parameter.IsGenericParameter)) { foreach (Type constraint in genericParameter.GetGenericParameterConstraints() - .OrderBy(static type => type.FullName, StringComparer.Ordinal)) + .OrderBy(static type => type.FullName, StringComparer.Ordinal)) { if (constraint == typeof(ValueType) || constraint == typeof(Enum)) { @@ -412,8 +408,14 @@ private static void VerifyType(Type? type, string? source, List violatio return; } - if (IsForbiddenSdkType(type, source, violations, declaringMember) || - IsUnsupportedFrameworkType(type, source, violations, declaringMember)) + if (IsNativeSized(type) && !IsApprovedNativeSizedMember(declaringMember)) + { + violations.Add($"{source} exposes a native-sized integer '{type}' that could disguise a native pointer."); + return; + } + + if (IsForbiddenSdkType(type, source, violations) || + IsUnsupportedFrameworkType(type, source, violations, declaringMember)) { return; } @@ -452,13 +454,12 @@ private static void VerifyType(Type? type, string? source, List violatio VerifyTypeMembers(type, source, violations, visited, ref visitedCount, depth); } - private static bool IsForbiddenSdkType(Type type, string? source, List violations, - MemberInfo? declaringMember) + private static bool IsForbiddenSdkType(Type type, string? source, List violations) { Type definition = type.IsGenericType ? type.GetGenericTypeDefinition() : type; string typeName = definition.FullName ?? definition.Name; if (definition.Name is "LuaState" or "LuaRef" or "CEObject" || - definition.Name.StartsWith("Owned`", StringComparison.Ordinal)) + definition.Name.StartsWith("Owned`", StringComparison.Ordinal)) { violations.Add($"{source} exposes forbidden SDK handle '{typeName}'."); return true; @@ -471,8 +472,7 @@ private static bool IsForbiddenSdkType(Type type, string? source, List v } if (definition.Assembly.GetName().Name?.StartsWith("CheatEngine.SDK", StringComparison.Ordinal) == true && - (!definition.IsValueType || !ApprovedSdkValueTypes.Contains(typeName)) && - !IsShippedRuntimeCapabilitiesDebt(definition, declaringMember)) + (!definition.IsValueType || !ApprovedSdkValueTypes.Contains(typeName))) { violations.Add($"{source} exposes non-approved SDK type '{typeName}'."); return true; @@ -486,7 +486,7 @@ private static bool IsUnsupportedFrameworkType(Type type, string? source, List + /// Scalars that can never disguise a native handle. and (nint, + /// nuint) are primitive to the runtime but are rejected unless explicitly allowlisted (A11-29). + /// private static bool IsScalar(Type type) { - return type.IsPrimitive || type == typeof(void) || type == typeof(string) || type == typeof(IntPtr) || - type == typeof(UIntPtr); + return (type.IsPrimitive && !IsNativeSized(type)) || type == typeof(void) || type == typeof(string); + } + + private static bool IsNativeSized(Type type) + { + return type == typeof(IntPtr) || type == typeof(UIntPtr); } private static bool IsTuple(Type type) @@ -526,29 +534,29 @@ private static bool IsFrameworkType(Type type) { string? assemblyName = type.Assembly.GetName().Name; return assemblyName is not null && - (assemblyName.StartsWith("System", StringComparison.Ordinal) || - assemblyName.StartsWith("Microsoft", StringComparison.Ordinal)); + (assemblyName.StartsWith("System", StringComparison.Ordinal) || + assemblyName.StartsWith("Microsoft", StringComparison.Ordinal)); } private static bool ShouldInspectTypeMembers(Type type) { return type.Assembly.IsDynamic || type.Assembly == typeof(PublicClientSignatureBoundaryTests).Assembly || - (type.IsValueType && - type.Assembly.GetName().Name?.StartsWith("CheatEngine.Client", StringComparison.Ordinal) == true); + (type.IsValueType && + type.Assembly.GetName().Name?.StartsWith("CheatEngine.Client", StringComparison.Ordinal) == true); } private static void VerifyTypeHierarchy(Type type, string? source, List violations, MemberInfo? declaringMember, HashSet visited, ref int visitedCount, int depth) { if (type.BaseType is { } baseType && baseType != typeof(object) && baseType != typeof(ValueType) && - baseType != typeof(Enum) && !IsRequiredPluginBase(baseType)) + baseType != typeof(Enum) && !IsRequiredPluginBase(baseType)) { VerifyType(baseType, $"{source} base type", violations, declaringMember, visited, ref visitedCount, depth + 1); } foreach (Type implementedInterface in type.GetInterfaces().OrderBy(static candidate => candidate.FullName, - StringComparer.Ordinal)) + StringComparer.Ordinal)) { if (IsSafeFrameworkDtoImplementationContract(implementedInterface)) { @@ -578,18 +586,18 @@ private static void VerifyTypeMembers(Type type, string? source, List vi ref int visitedCount, int depth) { const BindingFlags DeclaredInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | - BindingFlags.DeclaredOnly; + BindingFlags.DeclaredOnly; foreach (FieldInfo field in type.GetFields(DeclaredInstance).OrderBy(static candidate => candidate.Name, - StringComparer.Ordinal)) + StringComparer.Ordinal)) { VerifyType(field.FieldType, $"{source} field '{field.Name}'", violations, field, visited, ref visitedCount, depth + 1); } foreach (PropertyInfo property in type.GetProperties(DeclaredInstance).OrderBy( - static candidate => candidate.Name, - StringComparer.Ordinal)) + static candidate => candidate.Name, + StringComparer.Ordinal)) { VerifyType(property.PropertyType, $"{source} property '{property.Name}'", violations, property, visited, ref visitedCount, depth + 1); @@ -597,14 +605,14 @@ private static void VerifyTypeMembers(Type type, string? source, List vi } foreach (ConstructorInfo constructor in type.GetConstructors(DeclaredInstance).OrderBy(static candidate => - candidate.ToString(), StringComparer.Ordinal)) + candidate.ToString(), StringComparer.Ordinal)) { VerifyParameters(constructor.GetParameters(), constructor, violations, visited, ref visitedCount, depth); } foreach (MethodInfo method in type.GetMethods(DeclaredInstance) - .Where(static candidate => !candidate.IsPrivate) - .OrderBy(static candidate => candidate.ToString(), StringComparer.Ordinal)) + .Where(static candidate => !candidate.IsPrivate) + .OrderBy(static candidate => candidate.ToString(), StringComparer.Ordinal)) { if (IsObjectEqualityMethod(method)) { @@ -622,23 +630,14 @@ private static void VerifyTypeMembers(Type type, string? source, List vi private static bool IsObjectEqualityMethod(MethodInfo method) { return method.Name == nameof(object.Equals) && !method.IsStatic && method.ReturnType == typeof(bool) && - method.GetParameters() is [ParameterInfo { ParameterType: var type }] && type == typeof(object); - } - - private static bool IsShippedRuntimeCapabilitiesDebt(Type type, MemberInfo? declaringMember) - { - // PublicAPI.Shipped preserves this one legacy class reference. It is deliberately a member-level exception: - // every other SDK reference type remains prohibited by this recursive boundary test. - return type.FullName == "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities" && - declaringMember?.DeclaringType?.FullName == "CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot" && - declaringMember switch - { - FieldInfo { Name: "k__BackingField" } => true, - ConstructorInfo => true, - MethodInfo { Name: "get_SdkCapabilities" } => true, - PropertyInfo { Name: "SdkCapabilities" } => true, - _ => false - }; + method.GetParameters() is [ParameterInfo { ParameterType: var type }] && type == typeof(object); + } + + /// No public Client member may expose nint/nuint; add a reviewed, named exception here if one ever must. + private static bool IsApprovedNativeSizedMember(MemberInfo? declaringMember) + { + _ = declaringMember; + return false; } private static void AssertViolation(Type type, string expectedFragment) diff --git a/tests/CheatEngine.Client.Tests/README.md b/tests/CheatEngine.Client.Tests/README.md index 3dd13d5..c4b1678 100644 --- a/tests/CheatEngine.Client.Tests/README.md +++ b/tests/CheatEngine.Client.Tests/README.md @@ -21,10 +21,304 @@ through `CHEATENGINE_CLIENT_PACKAGE_SOURCE`, validates the template package, and not replace Core lifecycle tests or the opt-in live validation required for host-dependent capabilities such as value scans. +`PackageConsumptionSmokeTests` carries the traits `Category=PackageConsumption` and `Qualification=Q40`. The CI +Release leg points `CHEATENGINE_CLIENT_PACKAGE_SOURCE` at the packages it uploads, and without it a CI run fails +instead of packing its own. Locally, without the variable, the tests pack the repository into a temporary feed. +`PackagedClientFeedFixture` restores and builds everything once, from folders outside any repository, with an isolated +NuGet global packages folder and package source mapping (`CheatEngine.Client*` from the local feed only, everything +else from nuget.org). Every fact writes its evidence (package, bridge and hash values) to the test output, which the +TRX report keeps. The facts prove that: + +- `SevenPackagesAndFiveSymbolPackagesAreProduced`, `EveryClientPackageSharesOneVersion` and + `InterClientDependenciesRequireTheExactCoPackedVersion`: the seven packages share one MinVer version, the five + packages with build output carry a symbol package with PDBs, and each package depends on exactly the frozen set of + Client packages at exactly the co-packed version (`[X.Y.Z]`, not the `X.Y.Z` minimum NuGet writes by default); +- `SdkFacingPackagesDeclareThePinnedSdkRange`: Abstractions, Core and Hosting declare the pinned `CheatEngine.SDK` + range with frozen asset exclusions, and no other package depends on the SDK directly; +- `HostingPackageShipsOnlyTheGeneratorAssemblyAsAnalyzer` and `PackedAssembliesCarryTheMajorMinorAssemblyVersion`; +- `PackedReadmesContainNoRelativeLinks` and `EveryPackageNamesTheRepositoryCommitAndLicense`: nuget.org pages link + absolutely, and each package names the repository commit, MIT, the project and the changelog; +- `SymbolPackagesCarrySourceLinkToTheRepositoryCommit` and `EveryPackageEmbedsAnSpdxSbomDescribingItsOwnIdentity`; +- `PackedTemplateReferencesTheCoPackedClientAndThePinnedSdk`, + `PackedTemplateProjectDiffersFromTheRepositoryTemplateOnlyByStampedVersions` and + `TemplatePackageInstallsListsAndUninstallsAsync`; +- `IsolatedConsumerResolvesClientPackagesOnlyFromTheLocalFeed`: the Client packages come from the tested directory and + `CheatEngine.SDK` from nuget.org with the content hash of the reviewed SDK identity hardcoded in + `PackagedClientFeedFixture` (shared-contracts.md §2.4); +- `IsolatedConsumerDeploysTheCompleteClosureWithThePackagedBridge` and + `InstantiatedTemplateBuildsTheCompleteDeploymentClosure`: plugin, `.deps.json`, `.runtimeconfig.json`, Client and + SDK assemblies and the native bridge (hash equal to the packed one) sit side by side, in the output and in the + deployment folder (audit A04-09); +- `IsolatedConsumerDepsJsonRecordsPackagesWithoutWorkspacePaths`: `.deps.json` records the packages, with the SDK + library carrying the NuGet content hash of the lock (measured, audit A21-02), and no workspace path; +- `InstantiatedTemplateReferencesTheSdkDirectly` (audit A04-10) and + `PackagedClientPluginWithoutDirectSdkReferenceReportsCECLIENT001Async`; +- `PluginReferencingTheNextSdkMajorReportsCECLIENT017Async`: a plugin that references directly, next to the packed + Client, the pinned `CheatEngine.SDK` re-versioned to the major of the pin's upper bound fails its build with + `CECLIENT017`, both as a prerelease (inside the declared range, no NuGet warning) and as a stable release (NU1608); + `CheatEngineClientAllowUnsupportedSdk=true` turns the error into a warning; +- `PluginReferencingAnSdkBelowTheDeclaredRangeFailsRestoreAsync`: a plugin that references a re-versioned + `CheatEngine.SDK` below the declared range fails its restore with NU1605 (package downgrade). + +These are package-level results (fixture level C2); a Cheat Engine host run of Q40 is a separate qualification. +`PackageSourceResolutionTests` has no category, so both CI legs check the package source rules. + +`TemplateInstantiationTests` (`Category=PackageConsumption` and `Qualification=Q40`, same fixture and serial +collection, package-level results too) proves what `dotnet new ceplugin` generates from the packed template, beyond +the smoke build above: + +- `PackedTemplateCarriesItsGitIgnoreAndNoLockFile`: the package carries the `.gitignore` and no `packages.lock.json`, + and its project restores with a lock file; +- `InstantiatedTemplateLockFileRecordsThePinnedSdk`: the instance's lock file records the pinned `CheatEngine.SDK` with + its content hash, the co-packed `CheatEngine.Client`, and the `Microsoft.NET.ILLink.Tasks` that the .NET SDK adds; +- `TemplateDerivesDistinctPluginNamesAndLuaGlobalsFromTheProjectNameAsync` and + `DifferentProjectNamesCanDeriveTheSameLuaGlobalAsync`: the plugin name and Lua status global that each of a set of + project names derives, six distinct globals, and names that derive the same global; +- `TemplateRestoresWithALockFileUnlessNoRestoreIsPassedAsync`: the restore post action writes the lock file (it finds + the fixture's `NuGet.Config` above the temporary directory), and `--no-restore` leaves the project unrestored; +- `TemplateNameWithoutOutputCreatesTheNameFolderAsync`: `--name` without `--output` creates the name's folder; +- `InstantiatedTemplateBuildsWithWarningsAsErrorsUnderTheRepositoryCodeStyleAsync`: an instance builds with warnings as + errors, code style enforcement, the repository `.editorconfig` and its pinned `AnalysisLevel`, and the same build + refuses a style violation (`IDE0161`), so those rules are in effect. + +`ReadmeSnippetCompilationTests` (`Category=PackageConsumption`, same fixture and serial collection) reads the README +that each packed package publishes, and the repository README, and compiles every `csharp` block of one README as one +plugin project against the packed Client: the documented `CheatEngine.Client`, `CheatEngine.SDK` and +`Microsoft.Extensions.Configuration.Json` references (the last at the version the packed template stamps), `Nullable`, +`ImplicitUsings` and warnings as errors, with the Hosting plugin profile checks when a block declares a +`[CheatEnginePlugin]` type. Each block is written to `README.L.cs`, so a compiler error names the README line of +its block. `TheUmbrellaReadmeShowsACompiledPlugin` keeps a compiled plugin on the `CheatEngine.Client` page. +`EveryDocumentedPackageReferenceNamesTheCompiledVersion` proves that every `PackageReference` a README writes names the +version its blocks compile against (`X.Y.Z` for `CheatEngine.Client`, the SDK pin, the template's +`Microsoft.Extensions.Configuration.Json` version), and `TheUmbrellaReadmeDocumentsEveryPluginProjectReference` that +the `CheatEngine.Client` page writes all three references. The code block rules themselves (`ReadmeCodeBlocks`: a C# +block is labelled `csharp`, a `csharp nocompile` block carries `` on the line above it) are +proven on fixed Markdown by `ReadmeCodeBlocksTests`, which has no category. + +`ConsumerDiagnosticsTests` (`Category=PackageConsumption`, same fixture and serial collection) restores and builds, for +each case, a plugin project that consumes the packed packages and breaks one rule of the Hosting plugin profile, and +proves that the build fails with that rule's diagnostic and no other `CECLIENT` error: `CECLIENT002` (a plugin that +references `CheatEngine.Client.Hosting` instead of `CheatEngine.Client`), `CECLIENT005` (`net10.0-windows`), +`CECLIENT006` (C# 13), `CECLIENT007` (x86), `CECLIENT008` (the SDK entry point turned off without the manual bootstrap +acknowledgement), and the deployment prerequisites `CECLIENT011` (no `.deps.json`), `CECLIENT012` (no +`.runtimeconfig.json`), `CECLIENT013` (no `CheatEngine.SDK.dll` in the output) and `CECLIENT015` (no Lua bridge), each +of which leaves the deployment folder empty. `CECLIENT001` and `CECLIENT017` are the smoke facts above; the +repository test `ConsumerDiagnosticCatalogTests` keeps every emitted code, its help link and the Hosting README table +equal. + +`SymbolPackagesCarrySourceLinkToTheRepositoryCommit` expects Source Link URLs of +`https://raw.githubusercontent.com/CheatEngineNet/CheatEngine.Client//`, which the .NET SDK derives from the +`origin` remote of the checkout that packs. CI checks out this repository, so it holds there; a local run in a clone +whose `origin` is a fork or a local path produces other URLs or none, and fails that fact. + +`BuildGuardTests` run the repository's MSBuild guard targets against real projects with overridden global properties, +without restoring or building: `CommittedPinPassesTheSdkGuardAsync`, `NextMajorPinFailsWithCHEATENGINECLIENT9016Async` +and `PrereleaseSdkPinFailsWithCHEATENGINECLIENT9016Async` prove that the consumed `CheatEngine.SDK` pin cannot move to +the next major or to a prerelease package, and that such a pin never produces a package (the pack guard refuses it +with `CHEATENGINECLIENT9016` too). `RoslynPinDriftFailsWithCHEATENGINECLIENT9020Async` proves that the Roslyn pin of the +packed Lua generator cannot drift from its declared floor, and `LockstepGuardAcceptsMinVerAndRefusesEveryOtherVersionSourceAsync` +that a package version comes from MinVer only (`CHEATENGINECLIENT9019`). `SbomGuardRefusesAPackWithoutTheSbomAsync` proves +that a package cannot be packed without its SPDX SBOM (`CHEATENGINECLIENT9021`), and +`ShippingProjectWithoutTrimReferenceVerificationFailsWithCHEATENGINECLIENT9008Async` that a shipping project cannot be +packed with the trim-compatibility verification of its references (`VerifyReferenceTrimCompatibility`, IL2125) turned +off. + +The SDK versions that these guard cases, the `CHEATENGINECLIENT9050` pin-drift case of +`ConsumedSdkIdentityEmbeddingTests` and the two re-versioned SDK facts above probe are derived from +`eng/CheatEngineSdk.props` (`Infrastructure/SdkPin.cs`), so they keep testing the same boundaries when the pin moves. + +Two metadata suites read the built Client assemblies with `System.Reflection.Metadata`. `Architecture/` is the +ADR-01 ratchet. It freezes the direct Lua-stack and SDK-owner usages, the `[LuaGlobal]` inventory, and the absence of +native imports, and it rejects Client logging that could carry user data (Q46). A new debt entry fails the suite. When a +debt entry disappears, it must be deleted from its frozen list in the same change, so the lists only shrink. `SdkContract/` holds the Q48 consumer contracts against the consumed CheatEngine.SDK (the pin). It checks the shared +SDK type allowlist, the committed consumed-surface inventory, and the compile-only `SdkApiUsage` map. Both suites are +activation-independent and run in the Debug and Release test legs. + +## Live qualification + +`LiveQualification/` is the sandboxed Cheat Engine runner: C# test code, no script and no extra package. A live fact +builds plugins from the packed Client packages, loads them into a private copy of Cheat Engine 7.7.0.10621 x64 and +drives them against a disposable gtutorial target. Live facts carry `Category=LiveQualification` and a `Session=S0`..`S6` +trait and share the serial collection `Live qualification`. Both CI legs and every local gate exclude them by trait. They +are never skipped: without the opt-in they fail at once with the instructions below, and they also fail when `CI=true`. + +One session runs in this order: + +1. `HostProcessGuard` refuses to start while a `cheatengine-*`, `Cheat Engine` or `gtutorial*` process runs, or while + another debug output listener (DebugView) owns `DBWIN_BUFFER`. +2. `CheatEngineInstallation` verifies the source installation without writing to it: the SHA-256 of + `cheatengine-x86_64.exe` (`9727076D…`, the hash the harness gate pins), its file version 7.7.0.10621, its AMD64 + machine (read with `PEReader`) and the hashes of `gtutorial-x86_64.exe` (`2DABEFFD…`) and `gtutorial-i386.exe` + (`9131B1CA…`). It fingerprints the host executable and the `autorun` folder before the run and again after it. +3. `SandboxLayout` creates `/-<4 hex>/`. `CheatEngineRegistryGuard` then protects the user + state before anything can change it: + - it takes a recursive snapshot of `HKCU\Software\Cheat Engine` (every value name, type and raw data, every subkey) + into `/hkcu-backup.json`, reads it back, and copies `%APPDATA%\Cheat Engine` to `/appdata-backup/` with + its listing in `/appdata-backup.json`; a value type it cannot restore exactly stops the session; + - it writes the crash marker `/registry-restore-pending.json`, naming those backups, and only then + neutralizes the operator's plugin list for the session; + - after the session it deletes the key tree, recreates it from the snapshot and proves it equal, does the same for + the folder, and removes the marker only after both are verified; + - a marker found when a session begins means a previous run crashed: the guard restores and verifies that run's + backup first, then fails the new run with an explanation. If that restore fails, the marker stays and the message + names the backups to restore by hand. + + The guard only accepts `HKCU\Software\Cheat Engine` with `%APPDATA%\Cheat Engine`, or a test scratch key + `HKCU\Software\CheatEngine.Client.Tests\` with a temporary folder. The installation is then copied to + `/ce` and verified again. +4. `PluginBundleBuilder` compiles the harness sources in an isolated consumer outside any repository, against the + packed `CheatEngine.Client` of `CHEATENGINE_CLIENT_PACKAGE_SOURCE` and `CheatEngine.SDK` from nuget.org + (`PackagedClientFeedFixture`), and deploys the complete closure to `/plugins/`. +5. `TargetLauncher` starts the sandbox's gtutorial after checking its hash, and records its process id, start time and + modules; no `speedhack`, `allochook`, `luaclient`, `vehdebug` or `dbk` module may appear in it (Q45). +6. `AuthorizationManifestWriter` writes the `ce77-live-probe-v1` manifest: the pinned host, the target's process id and + hash, `disposable`, and an expiry at most 25 minutes ahead. The harness gate (`QualificationAuthorization`, compiled + into this project) accepts it, and it accepts nothing longer than 30 minutes. For fault scenarios the writer also + writes `liveprobe.fault.json` next to the plugin. +7. `LuaDriverScript` generates `/ce/autorun/zz_cheatengine_client_qualification.lua`: a `createTimer` state machine + on the main thread, one step per tick, each under `pcall`. It waits for the main form, opens the target, loads the + plugin, waits for the harness functions, calls them, inspects the settings form read-only, clears the address list, + writes `DONE` and calls `closeCE()`. Each step appends `Rstepok|error|notexecuted%q` to the transcript. + Until the spike proves that `getSettingsForm()` can perform them, the plugin toggles through Settings > Plugins are + operator steps (plan A12): a window that leaves Cheat Engine usable shows the prompt, and the driver waits up to 90 + seconds for the plugin's function to disappear after a disable or come back after an enable, or, for the enable that + must fail, for the operator's Done. The observed effect is recorded `ok`; Skip, closing the window or no action in + time is recorded `notexecuted`. +8. `DebugOutputCapture` owns the DBWIN objects (4096-byte section, `DBWIN_BUFFER_READY` and `DBWIN_DATA_READY`) and keeps + only the Cheat Engine process's messages (`DebugOutputBuffer`). +9. `HostProcessGuard` starts `/ce/cheatengine-x86_64.exe` directly, never the launcher, with an environment + stripped of `DOTNET_*`, `MSBUILD*`, `TESTINGPLATFORM*`, `VSTEST*` and every inherited `CHEATENGINE_*`, + `CE_SDK_LIVE_PROBE_*` and `CECLIENT_QUALIFICATION_*` value. It adds only the session's `CE_SDK_LIVE_PROBE_*` and + `CECLIENT_QUALIFICATION_*` inputs and `CHEATENGINE_SDK_IDENTIFY_ON_ENABLE=1`. A session that exceeds 10 minutes is + closed, then killed, and marked `TimedOut`. A `finally` always stops Cheat Engine and the target and restores the user + state. +10. `TranscriptParser` decodes the transcript (Lua `%q` escapes). `ReceiptLedger` writes `receipts.jsonl` + (`cheatengine-client-qualification-receipt/v1`): the run directory becomes ``, and a receipt that still holds + a local path, the user name or the machine name is refused. `QualificationSummaryWriter` writes `summary.json` + (`cheatengine-client-qualification-summary/v1`): the package, SDK, host and target tuple with the + `qualifiedSourceDigest`, one verdict per scenario and capability derived from the receipts (NotExecuted unless every + check passed or one failed), and `registryRestored`. + +`QualifiedSourceDigest` binds evidence to the shipping sources. It is the SHA-256 of a `sha256sum`-style manifest (one +` ` line per input, ordinal order) over `libs/**`, `src/**`, `source-generators/**` and `templates/**` +(lock files included), `Directory.Build.*`, `Directory.Packages.props`, `eng/*.props` and `global.json`, each with CRLF +normalized to LF. It excludes `*.md`, `PublicAPI.*.txt`, `AnalyzerReleases.*.md` and `HostQualificationEvidence.cs`, and +skips what `.gitignore` excludes (`bin`, `obj`, `artifacts` and tool folders). The same source file is compiled into +`CheatEngine.Client.Repository.Tests`, whose evidence tests recompute it; a change to any input after a recorded run +requires a new run. + +`LiveSandboxSpikeTests` (`Session=S0`) is the spike: it loads the harness on gtutorial-x86_64, calls `status`, `runtime` +and `capabilities(1)`, inspects the settings form, asks the operator to disable and enable the harness, and closes Cheat +Engine, then requires the user state restored, the source installation unchanged and no process left. The facts the spike establishes are still pending and will be +recorded here: the registry values of the plugin list, whether the settings toggle is feasible, the dialogs Cheat Engine +shows, what disable does at `closeCE`, what `loadPlugin` enables, whether elevation is needed, whether hostfxr needs +`DOTNET_ROOT` once `DOTNET_*` is removed, and the exact behaviour of the driver's `openFileAsProcess` call. Until the +plugin-list values are known, the guard neutralizes none of them, so the operator's own Cheat Engine plugins would load in +the sandbox too; the guard still restores whatever the session changes. Spike receipts are never committed. + +The sessions of the qualification are the live facts of `LiveSessionTests` (`Session=S1` to `S6`, one `Qualification` +trait per scenario), described as data in `SessionPlans` (setup and driver steps), `ScenarioCatalog` (scenarios, levels, +sessions, release gate and the capability map) and `ScenarioEvaluators` (one C# evaluator per check): + +- **S1**, the x64 core on gtutorial-x86_64, with the Auto Assembler opt-in, a table root below the session directory and + the lifecycle sink: identity (Q05), bundle identity (Q40), the capability probe (Q45), round trips (Q20, Q21), the + partial batch (Q33), `setPointerSize(4)` then 8 (Q31), target facts and instructions (Q32), AOB scans (Q27–Q29), value + scans (Q25, Q26), an allocation (Q30.a), Auto Assembler patches (Q35), tables (Q34), a symbol lease (Q16.b), worker + admission (Q19), the 2^53 marshalling rule (Q21) and logs (Q46). +- **S2**, lifecycle and faults, without the Auto Assembler opt-in and with the `ModuleOnDisabling` fault: the policy + refusal (Q44), the operator toggles of Q05, Q06 and the kept-function check of Q16, and the last disable read from + the lifecycle sink (Q43), the one at `closeCE` or else the operator's last. The `Configure` fault of Q06 is written + for one enable only; the switch then selects `ModuleOnDisabling` again, so every later enable keeps the fault Q43 + reads. +- **S3**, target identity on two gtutorial-x86_64 instances: an allocation, a scan session and a patch on A, then + `openProcess(B)` (each lease has ended with a `RefusedTargetChanged` release that requires manual recovery, and a + release attempt on B returns that refusal again without any Cheat Engine call), back on A (the refused leases stay + ended; a new allocation releases), and a copy opened as a file (no allocation or scan session, + `TargetIdentityUnavailable`; the AOB fallback route). Each process Cheat Engine selects is the one the Client reports + next, with a later selection epoch (Q26, Q28, Q30.a, Q32, Q35). Q30.b, the reuse of a process id, cannot be produced + on demand and stays NotExecuted (waivable, plan A12). A test proves that every driver step is read by a check or is + reviewed setup. +- **S4**, the x86 target gtutorial-i386: bitness 4, an address above 4 GiB refused, the x86 module scan and instruction + profile (Q21, Q28, Q32). +- **S5a** and **S5b**, coexistence in two load orders (A, then the SDK 1.x neighbour, then B; and the neighbour, A, B): + Q09, Q10 and Q16. The neighbour is a plain CheatEngine.SDK 1.x plugin generated in a temporary consumer + (`NeighbourPluginSource`) that reports its identity as booleans only. +- **S6**, the template instantiated from the packed Templates package as `QualTemplatePlugin`, bundled and loaded (Q40). + +The release gate is Q09, Q10, Q40, Q43, Q44, Q45 and Q46 on the host, plus Q48 in CI (`SdkConsumerContractTests`). The +sessions share one run directory, one `receipts.jsonl` and one `summary.json`, rewritten after each session. Run S2 and +S5 with the operator at the keyboard: a check that needs a plugin toggle through Settings > Plugins is NotExecuted +unless the driver saw that toggle done, and the Q43 checks are NotExecuted when no enable was disabled, neither by the +operator nor at `closeCE` (a spike fact). The Q05 identification check reads the `CheatEngineSdkIdentification` line +that CheatEngine.SDK 2.0.0 writes through its debug output sink. A live fact fails when a check fails or the workstation +is not left as it was; a NotExecuted check is recorded, never turned into a pass. Run one session with the opt-in above +and `--filter-trait Session=S1` (up to `S6`). Run all the recorded sessions with +`--filter-trait Category=LiveQualification --filter-not-trait Session=S0`, the command `RELEASING.md` gives: it leaves +out the S0 spike, whose receipts are never committed. + +Run it from the repository root, in PowerShell, with Cheat Engine, every gtutorial and DebugView closed: + +```powershell +dotnet build CheatEngine.Client.slnx -c Release --no-restore +dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget +$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path artifacts/nuget).Path +$env:CHEATENGINE_CLIENT_LIVE_QUALIFICATION = 'I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET' +dotnet test --project tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj -c Release --no-build --filter-trait Session=S0 +Remove-Item Env:CHEATENGINE_CLIENT_LIVE_QUALIFICATION +``` + +Optional inputs: `CHEATENGINE_CLIENT_LIVE_QUALIFICATION_CE_DIRECTORY` (default `C:/Program Files/Cheat Engine`, only +ever read) and `CHEATENGINE_CLIENT_LIVE_QUALIFICATION_RUN_ROOT` (default +`%LOCALAPPDATA%/CheatEngine.Client.LiveQualification/runs`, refused inside the repository or the installation). Each +run keeps its directory, sandbox included, for inspection; delete old runs by hand. + +The runner's decisions are unit-tested in both CI legs, without starting anything: `LiveQualificationOptInTests` (the +opt-in, CI refusal, required packages, run root placement, the command above, and that every live fact is serial, +traited and never skipped), `CheatEngineInstallationTests` (fake files: hashes, machine, version, sandbox copy, +fingerprint, run directories), `AuthorizationManifestTests` (the harness gate and fault switch accept what the runner +writes), `LuaDriverScriptTests` (the reviewed driver text), `TranscriptParserTests`, `ReceiptLedgerTests`, +`QualificationSummaryWriterTests`, `DebugOutputBufferTests` (process id filter and ANSI decoding), +`HostProcessGuardTests` (blocking process names, the sandbox environment, injected modules), +`LiveSandboxSessionTests` (the S0 receipts derived from a transcript and the workstation checks), `ScenarioCatalogTests` +(every scenario has checks in the sessions it names, the release gate and the capability map are covered, each live +fact carries its scenario traits), `SessionPlanTests` (the reviewed step order of every session, harness calls only, +operator toggles never attempted, every step a check reads exists), `EvaluatorTests` (every kind of check on canned +evidence, passing and failing), `NeighbourPluginSourceTests` (the generated SDK 1.x neighbour, compared by hand once +with the SDK's published 1.x quick start and never read from the SDK repository), and the serial +`RegistrySnapshotTests` and `RegistryRecoveryTests`. The last two write the registry, but only a test-owned scratch key +`HKCU\Software\CheatEngine.Client.Tests\` and a temporary folder standing for `%APPDATA%\Cheat Engine`; they delete +the scratch key afterwards, and its parent `HKCU\Software\CheatEngine.Client.Tests` once it is empty. They never open +`HKCU\Software\Cheat Engine`. `RegistrySnapshotTests` round-trips every value type through the backup and restore and +proves which keys the guard accepts; `RegistryRecoveryTests` proves the neutralized session state, the verified restore, +the restore on dispose, the crash marker recovery that fails the next run, the marker kept while a restore does not +verify, and the removal of a `%APPDATA%` folder the session created. `QualifiedSourceDigestTests` proves that a CRLF and an +LF checkout hash the same, that the input and exclusion lists are exactly the plan's, that content and path changes move +the digest while excluded files never do, and, running `git ls-files`, that every tracked input is enumerated and no file +git ignores is. + ## Run From the repository root: ```powershell -dotnet test --project .\tests\CheatEngine.Client.Tests\CheatEngine.Client.Tests.csproj --configuration Release +dotnet test --project .\tests\CheatEngine.Client.Tests\CheatEngine.Client.Tests.csproj --configuration Release --filter-not-trait "Category=LiveQualification" ``` + +Without `CHEATENGINE_CLIENT_PACKAGE_SOURCE`, that run packs the repository itself. To test the exact packed files, as +the CI Release leg does: + +```powershell +dotnet build CheatEngine.Client.slnx --configuration Release +dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --output ./artifacts/nuget +$env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = (Resolve-Path ./artifacts/nuget).Path +dotnet test --project ./tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj --configuration Release --no-build --fail-skips on --filter-not-trait "Category=LiveQualification" +``` + +Without the package consumption tests (the CI Debug leg): + +```powershell +dotnet test --project ./tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj --configuration Debug --no-build --fail-skips on --filter-not-trait "Category=PackageConsumption" --filter-not-trait "Category=LiveQualification" +``` + +Both CI legs, and every command above, exclude the live qualification tests (`Category=LiveQualification`) by trait: +they start a sandboxed Cheat Engine and run only on a maintainer workstation that opts in. diff --git a/tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkIdentityEmbeddingTests.cs b/tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkIdentityEmbeddingTests.cs new file mode 100644 index 0000000..93c8267 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkIdentityEmbeddingTests.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Reflection; +using System.Text.Json; + +using CheatEngine.Client.Tests.Architecture; +using CheatEngine.Client.Tests.Infrastructure; + +namespace CheatEngine.Client.Tests.SdkContract; + +/// +/// The consumed CheatEngine.SDK identity that Client.Core embeds for its runtime package gate (audit ADR-09, ADR-10, +/// A21-35) is the locked and restored package: the version and content hash of the resolved lock-file entry, the pin +/// of eng/CheatEngineSdk.props, the source commit of the CheatEngine.SDK.Engine assembly actually loaded, and +/// the supported major of eng/CheatEngineSdk.props. A build that cannot embed it fails with +/// CHEATENGINECLIENT9050. +/// +/// +/// The guard cases run only the evaluation and the guard target of CheatEngine.Client.Core.csproj (nothing is +/// restored, built or packed), like BuildGuardTests; they need the restored checkout the solution build leaves. +/// +public sealed class ConsumedSdkIdentityEmbeddingTests +{ + private const string MetadataPrefix = "CheatEngine.Client.ConsumedSdk."; + private const string CoreProject = "libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj"; + private const string CoreLockFile = "libs/CheatEngine.Client.Core/packages.lock.json"; + private const string IdentityGuard = "CheatEngineClientRequireConsumedSdkIdentity"; + + [Fact] + [Trait("Qualification", "Q48")] + public void EmbeddedSdkIdentityEqualsTheLockedAndLoadedSdkPackage() + { + using JsonDocument lockFile = JsonDocument.Parse(File.ReadAllText(RepositoryLayout.Combine(CoreLockFile))); + JsonElement lockedSdk = lockFile.RootElement.GetProperty("dependencies").GetProperty("net10.0") + .GetProperty("CheatEngine.SDK"); + string? loaded = ClientAssemblyCatalog.Load("CheatEngine.SDK.Engine") + .GetCustomAttribute()?.InformationalVersion; + Dictionary embedded = ReadEmbeddedMetadata(); + + Assert.NotNull(loaded); + Assert.Equal( + new Dictionary(StringComparer.Ordinal) + { + [MetadataPrefix + "Version"] = lockedSdk.GetProperty("resolved").GetString()!, + [MetadataPrefix + "SourceCommit"] = loaded[(loaded.IndexOf('+', StringComparison.Ordinal) + 1)..], + [MetadataPrefix + "ContentHashSha512"] = lockedSdk.GetProperty("contentHash").GetString()!, + [MetadataPrefix + "SupportedMajor"] = SdkPin.SupportedMajor.ToString(CultureInfo.InvariantCulture) + }, + embedded); + Assert.Equal(SdkPin.Version, embedded[MetadataPrefix + "Version"]); + Assert.Equal($"{embedded[MetadataPrefix + "Version"]}+{embedded[MetadataPrefix + "SourceCommit"]}", loaded); + } + + [Fact] + [Trait("Qualification", "Q48")] + public async Task CommittedLockAndRestoredPackagePassTheConsumedSdkIdentityGuardAsync() + { + DotNetProcessResult result = await RunGuardAsync(); + + Assert.True(result.ExitCode == 0, result.ToString()); + Assert.DoesNotContain("CHEATENGINECLIENT9050", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q48")] + public async Task BuildThatCannotEmbedTheConsumedSdkIdentityFailsWithCHEATENGINECLIENT9050Async() + { + using TemporaryDirectory emptyPackageRoot = new("empty-package-root"); + string driftedPin = $"{SdkPin.Major}.{SdkPin.Minor}.{SdkPin.Patch + 1}"; + + DotNetProcessResult packageMissing = await RunGuardAsync($"-p:NuGetPackageRoot={emptyPackageRoot.Path}"); + DotNetProcessResult pinDrift = await RunGuardAsync($"-p:CheatEngineSdkVersion={driftedPin}"); + + Assert.True(packageMissing.ExitCode != 0, packageMissing.ToString()); + Assert.Contains("error CHEATENGINECLIENT9050", packageMissing.StandardOutput, StringComparison.Ordinal); + Assert.Contains("package was not found under the NuGet package root", packageMissing.StandardOutput, + StringComparison.Ordinal); + Assert.True(pinDrift.ExitCode != 0, pinDrift.ToString()); + Assert.Contains("error CHEATENGINECLIENT9050", pinDrift.StandardOutput, StringComparison.Ordinal); + Assert.Contains($"not the pin {driftedPin} of eng/CheatEngineSdk.props", pinDrift.StandardOutput, + StringComparison.Ordinal); + } + + private static Task RunGuardAsync(params string[] properties) + { + List arguments = + [ + "msbuild", RepositoryLayout.Combine(CoreProject), $"-t:{IdentityGuard}", "-nologo", "-nodeReuse:false", + "-verbosity:minimal" + ]; + arguments.AddRange(properties); + return DotNetProcess.RunAsync(RepositoryLayout.Root, [.. arguments]); + } + + private static Dictionary ReadEmbeddedMetadata() + { + Dictionary metadata = new(StringComparer.Ordinal); + foreach (CustomAttributeData attribute in ClientAssemblyCatalog.Load("CheatEngine.Client.Core") + .GetCustomAttributesData()) + { + if (attribute.AttributeType == typeof(AssemblyMetadataAttribute) && + attribute.ConstructorArguments[0].Value is string key && + key.StartsWith(MetadataPrefix, StringComparison.Ordinal)) + { + metadata.Add(key, (string) attribute.ConstructorArguments[1].Value!); + } + } + + return metadata; + } +} diff --git a/tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkSurface.cs b/tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkSurface.cs new file mode 100644 index 0000000..98dd80e --- /dev/null +++ b/tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkSurface.cs @@ -0,0 +1,494 @@ +namespace CheatEngine.Client.Tests.SdkContract; + +/// +/// The committed inventory of the consumed CheatEngine.SDK surface (the pin of eng/CheatEngineSdk.props) +/// that the shipped Client assemblies reference (Q48). +/// +/// +/// +/// One line per consuming Client assembly and SDK type (T) or member (M) reference, read from the +/// compiled metadata and sorted ordinally. Generic instantiations are reduced to their definitions. +/// +/// +/// A change here is a reviewed change of the SDK coupling. When the Client starts or stops using an SDK +/// member, SdkConsumerContractTests.ConsumedSdkSurfaceMatchesTheCommittedInventory fails and prints the +/// added and removed lines: update this list and, for a new member, . +/// +/// +internal static class ConsumedSdkSurface +{ + internal static readonly string[] Lines = + [ + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Inspection.ModuleName::get_Value()->string", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.CheatEngineVersion::get_Major()->int32", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.CheatEngineVersion::get_Minor()->int32", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Bit32()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Bit64()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Bytes()->int32", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.PointerSize::get_IsKnown()->boolean", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Unknown()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Values.Address::.ctor(uint64)->void", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Values.Address::get_IsZero()->boolean", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Values.Address::get_Zero()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Values.Address::op_GreaterThanOrEqual(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address)->boolean", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Values.Address::op_LessThan(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address)->boolean", + "CheatEngine.Client.Abstractions M CheatEngine.SDK.Engine.Values.Address::op_LessThanOrEqual(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address)->boolean", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.AddressList.MemoryRecordId", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Enums.VariableType", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.ModuleInfo", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.ModuleName", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.SymbolExpression", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.SymbolInfo", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Runtime.TargetAbi", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Runtime.TargetBackend", + "CheatEngine.Client.Abstractions T CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressList::TryCreateMemoryRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecord&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressList::TryGetCount(int32&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressList::TryGetMemoryRecord(int32,CheatEngine.SDK.Engine.AddressList.MemoryRecord&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressList::TryGetMemoryRecordById(CheatEngine.SDK.Engine.AddressList.MemoryRecordId,CheatEngine.SDK.Engine.AddressList.MemoryRecord&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressList::TryGetSelectedRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecord&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressList::TrySetSelectedRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecord)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressListAccess::TryGetCurrent(CheatEngine.SDK.Engine.AddressList.AddressList&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressListMutations::Delete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId)->CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressListMutations::SetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId,boolean)->CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.AddressListMutations::SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId,System.Nullable`1,CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit)->CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetActive(boolean&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetAddressExpression(string&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetAsync(boolean&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetAsyncProcessing(boolean&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetChild(int32,CheatEngine.SDK.Engine.AddressList.MemoryRecord&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetCurrentAddress(CheatEngine.SDK.Engine.Values.Address&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetDescription(string&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetId(CheatEngine.SDK.Engine.AddressList.MemoryRecordId&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetIndex(int32&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetOffsetCount(int32&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetScript(string&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetValue(string&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TryGetVariableType(CheatEngine.SDK.Engine.Enums.VariableType&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TrySetAddressExpression(string)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TrySetDescription(string)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TrySetValue(string)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::TrySetVariableType(CheatEngine.SDK.Engine.Enums.VariableType)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecord::get_Handle()->CheatEngine.SDK.Engine.Objects.CEObject", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome::get_Kind()->CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome::get_Problem()->CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordId::get_Value()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordId::op_Equality(CheatEngine.SDK.Engine.AddressList.MemoryRecordId,CheatEngine.SDK.Engine.AddressList.MemoryRecordId)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome::get_Effect()->CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome::get_IsCompleted()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome::get_Problem()->CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit::.ctor(int32)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.AllocatedRegion::ReleaseWithTargetOutcome()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.AllocatedRegion::get_IsDisposed()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.AllocatedRegion::get_LastReleaseOutcome()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.AllocatedRegion::get_TargetIncarnation()->CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome::get_Allocation()->CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome::get_Compensation()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome::get_Effect()->CheatEngine.SDK.Engine.Objects.EngineEffectState", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome::get_HasOwner()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest::.ctor(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize,System.Nullable`1,System.Nullable`1)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetAllocationSize::.ctor(int64)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome::get_Address()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome::get_Operation()->CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator::.ctor()->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator::TryAllocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest,CheatEngine.SDK.Engine.Allocation.AllocatedRegion&)->CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome::get_Kind()->CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_Compensation()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_Effect()->CheatEngine.SDK.Engine.Objects.EngineEffectState", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_HostText()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_HostTextTruncated()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_HostWarnings()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_HostWarningsTruncated()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_Kind()->CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome::get_LuaStatus()->CheatEngine.SDK.Lua.Calls.LuaStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome::get_HostText()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome::get_HostTextTruncated()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome::get_Kind()->CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome::get_LuaStatus()->CheatEngine.SDK.Lua.Calls.LuaStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions::.ctor()->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions::set_CaptureHostText(boolean)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions::set_MaxDisableInfoEntries(int32)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions::set_MaxDisableInfoNameBytes(int32)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions::set_MaxHostTextBytes(int32)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch::ReleaseWithTargetOutcome()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch::get_IsDisposed()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch::get_IsEnabled()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch::get_LastReleaseOutcome()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch::get_RequiresManualRecovery()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch::get_TargetIncarnation()->CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher::TryApplyWithOutcome(string,CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions,CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch&)->CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher::TryCheck(string,boolean,CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions)->CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionAssembler::TryAssemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile,string,CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Assembly.AssemblePreference,boolean,System.Span`1,CheatEngine.SDK.Engine.Assembly.InstructionAssembly&)->CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionAssembly::get_RequiredLength()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionAssembly::get_Written()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionDisassembler::TryDisassemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile,CheatEngine.SDK.Engine.Values.Address,int32,CheatEngine.SDK.Engine.Assembly.InstructionDisassembly&,int32&)->CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionDisassembly::get_AddressText()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionDisassembly::get_Extra()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionDisassembly::get_Opcode()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionNavigator::TryGetLength(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile,CheatEngine.SDK.Engine.Values.Address,int32&)->CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionNavigator::TryGetPrevious(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile,CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address&)->CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionProfile::get_AddressWidth()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionProfile::get_Architecture()->CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionProfiles::TryObserveCurrent(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile&)->CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile::get_Profile()->CheatEngine.SDK.Engine.Assembly.InstructionProfile", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile::get_Target()->CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Errors.EngineException::get_Kind()->CheatEngine.SDK.Engine.Errors.EngineFailureKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Errors.EngineMarshallingException::.ctor(string,CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection,string,string)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions::.ctor(boolean)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::EnumerateMemoryRegions(System.Span`1,int32&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::EnumerateModules(CheatEngine.SDK.Engine.Inspection.TargetProcessId,System.Span`1,int32&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::EnumerateModules(System.Span`1,int32&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::EnumerateSections(CheatEngine.SDK.Engine.Inspection.ModuleName,System.Span`1,int32&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::GetMemoryRegionInfo(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::GetSymbolInfo(CheatEngine.SDK.Engine.Inspection.SymbolExpression,CheatEngine.SDK.Engine.Inspection.SymbolInfo&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.EngineInspection::ResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression,CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions,CheatEngine.SDK.Engine.Values.Address&)->CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.MemorySize::get_Value()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.ModuleInfo::get_BaseAddress()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.ModuleInfo::get_ImageSize()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.ModuleInfo::get_Name()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.ModuleName::get_Value()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolExpression::.ctor(string)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolExpression::get_Value()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolName::.ctor(string)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome::get_Lease()->CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome::get_Status()->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease::Release()->CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions::.ctor(boolean)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome::get_Kind()->CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistry::TryGetName(CheatEngine.SDK.Engine.Values.Address,string&)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.SymbolRegistry::TryRegisterOwned(CheatEngine.SDK.Engine.Inspection.SymbolName,CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions)->CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.TargetProcessId::.ctor(int32)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.TargetProcessId::get_Value()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.TargetProcessId::op_Equality(CheatEngine.SDK.Engine.Inspection.TargetProcessId,CheatEngine.SDK.Engine.Inspection.TargetProcessId)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Inspection.TargetProcessId::op_Inequality(CheatEngine.SDK.Engine.Inspection.TargetProcessId,CheatEngine.SDK.Engine.Inspection.TargetProcessId)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadBytes(CheatEngine.SDK.Engine.Values.Address,System.Span`1,int32&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadDouble(CheatEngine.SDK.Engine.Values.Address,double&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadInt16(CheatEngine.SDK.Engine.Values.Address,int16&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadInt32(CheatEngine.SDK.Engine.Values.Address,int32&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadInt64(CheatEngine.SDK.Engine.Values.Address,int64&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadInt8(CheatEngine.SDK.Engine.Values.Address,sbyte&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadPointer(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Runtime.PointerSize,CheatEngine.SDK.Engine.Values.Address&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadSingle(CheatEngine.SDK.Engine.Values.Address,single&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadString(CheatEngine.SDK.Engine.Values.Address,int32,boolean,string&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadUInt16(CheatEngine.SDK.Engine.Values.Address,uint16&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadUInt32(CheatEngine.SDK.Engine.Values.Address,uint32&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadUInt64(CheatEngine.SDK.Engine.Values.Address,uint64&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryReadUInt8(CheatEngine.SDK.Engine.Values.Address,byte&,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteBytes(CheatEngine.SDK.Engine.Values.Address,System.ReadOnlySpan`1,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteDouble(CheatEngine.SDK.Engine.Values.Address,double,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteInt16(CheatEngine.SDK.Engine.Values.Address,int16,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteInt32(CheatEngine.SDK.Engine.Values.Address,int32,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteInt64(CheatEngine.SDK.Engine.Values.Address,int64,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteInt8(CheatEngine.SDK.Engine.Values.Address,sbyte,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWritePointer(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Runtime.PointerSize,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteSingle(CheatEngine.SDK.Engine.Values.Address,single,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteString(CheatEngine.SDK.Engine.Values.Address,System.ReadOnlySpan`1,boolean,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteUInt16(CheatEngine.SDK.Engine.Values.Address,uint16,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteUInt32(CheatEngine.SDK.Engine.Values.Address,uint32,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteUInt64(CheatEngine.SDK.Engine.Values.Address,uint64,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Memory.TargetMemory::TryWriteUInt8(CheatEngine.SDK.Engine.Values.Address,byte,CheatEngine.SDK.Engine.Memory.MemoryAccessFailure&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Objects.CEObject::TryGetProperty``2(System.ReadOnlySpan`1,!!1&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Objects.Owned`1::ReleaseWithOutcome()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Objects.Owned`1::get_Value()->!0", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Objects.StringList::TryGetCount(int32&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Objects.StringList::TryGetItem(int32,string&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.CurrentProcessObservation::get_Id()->CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.CurrentProcessObservation::get_PointerSize()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.ProcessOperationStatus::get_GlobalUnavailable()->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.ProcessOperationStatus::get_IsSuccess()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.ProcessOperationStatus::get_Kind()->CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.ProcessOperationStatus::get_Success()->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.ProcessOperationStatus::get_TargetChanged()->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.ProcessOperationStatus::get_TargetNotAttached()->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeHostOperations::ObserveHost(CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation&)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeHostOperations::TryGetCheatEngineFileVersion(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion&)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeHostOperations::TryGetOperatingSystem(CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem&)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeHostOperations::TryGetSystemArchitecture(CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture&)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeHostOperations::TryIsCheatEngine64Bit(boolean&)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeObservations::TryObserveRuntimeInfo(CheatEngine.SDK.Engine.Runtime.RuntimeInfo&)->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations::ObserveCurrent(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation&)->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations::ObserveTargetArchitecture(CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation&)->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations::SelectAndObserve(CheatEngine.SDK.Engine.Inspection.TargetProcessId,CheatEngine.SDK.Engine.Processes.CurrentProcessObservation&)->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations::TryGetConfiguredPointerSize(int32&,CheatEngine.SDK.Engine.Runtime.PointerSize&)->CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation::.ctor(System.Nullable`1,CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture,System.Nullable`1,CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation::get_CheatEngineIs64Bit()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation::get_FileVersion()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation::get_OperatingSystem()->CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation::get_SystemArchitecture()->CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineVersion::get_Ce77010621()->CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.CheatEngineVersion::op_Inequality(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion,CheatEngine.SDK.Engine.Runtime.CheatEngineVersion)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Bit32()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Bit64()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Bytes()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.PointerSize::get_IsKnown()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.PointerSize::get_Unknown()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.PointerSize::op_Inequality(CheatEngine.SDK.Engine.Runtime.PointerSize,CheatEngine.SDK.Engine.Runtime.PointerSize)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities::GetState(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId)->CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities::TryGet(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId,CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability::get_State()->CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId::get_CurrentProcess()->CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId::get_TargetArchitecture()->CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeInfo::get_Capabilities()->CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeInfo::get_Host()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.RuntimeInfo::get_Target()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::.ctor(CheatEngine.SDK.Engine.Inspection.TargetProcessId,CheatEngine.SDK.Engine.Runtime.TargetBackend,CheatEngine.SDK.Engine.Runtime.PointerSize,System.Nullable`1,System.Nullable`1,System.Nullable`1,System.Nullable`1,System.Nullable`1)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_Abi()->CheatEngine.SDK.Engine.Runtime.TargetAbi", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_Architecture()->CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_Backend()->CheatEngine.SDK.Engine.Runtime.TargetBackend", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_Bitness()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_ConfiguredPointerSize()->CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_ConfiguredPointerSizeBytes()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_ConfiguredPointerSizeDiffersFromBitness()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_IsAndroid()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation::get_ProcessId()->CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_AtOrAfterStopSkipped()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_BelowStartSkipped()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_CopyElapsed()->System.TimeSpan", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_Creation()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_HostErrorText()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_HostResultCount()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_HostScanElapsed()->System.TimeSpan", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_IsHostErrorTextTruncated()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_IsHostErrorTextUnreadable()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_IsMaterializationLimitReached()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_Kind()->CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_LuaStatus()->CheatEngine.SDK.Lua.Calls.LuaStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_Release()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_RowsRead()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_UnreadHostRows()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult::get_Written()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds::TryCreate(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds::TryFromModule(CheatEngine.SDK.Engine.Inspection.ModuleInfo&,CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds::get_Start()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds::get_Stop()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions::.ctor(string,CheatEngine.SDK.Engine.Enums.FastScanMethod,string)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome::get_Kind()->CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome::get_LuaStatus()->CheatEngine.SDK.Lua.Calls.LuaStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome::get_ResultCount()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext::get_After()->CheatEngine.SDK.Engine.Targets.TargetSelectionObservation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext::get_Before()->CheatEngine.SDK.Engine.Targets.TargetSelectionObservation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanner::TryScanOutcome(string,CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions,CheatEngine.SDK.Engine.Objects.Owned`1&,CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext&)->CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Aob.AobScanner::TryScanWithinBounds(string,CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds,CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions,System.Span`1,System.Threading.CancellationToken)->CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest::.ctor(CheatEngine.SDK.Engine.Enums.ScanOption,CheatEngine.SDK.Engine.Enums.VariableType,CheatEngine.SDK.Engine.Enums.RoundingType,string,string,CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address,string,CheatEngine.SDK.Engine.Enums.FastScanMethod,string,boolean,boolean,boolean,boolean)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome::get_Status()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome::get_TargetObservation()->CheatEngine.SDK.Engine.Targets.TargetSelectionObservation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException::get_FailureKind()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome::get_FoundList()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome::get_MemScan()->CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome::get_Termination()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult::get_Address()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult::get_Value()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::ReleaseWithOutcome()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::ResetCancellable(System.Threading.CancellationToken)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::StartFirstScanCancellable(CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest&,System.Threading.CancellationToken)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::StartNextScanCancellable(CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest&,System.Threading.CancellationToken)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::TryCopyResultsPageCancellable(int32,System.Span`1,uint64&,int32&,System.Threading.CancellationToken)->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::TryGetHostErrorText(string&,boolean&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::WaitForCompletionCancellable(System.Threading.CancellationToken)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::get_InvalidationReason()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::get_LastCancellationMilestone()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::get_ResultCount()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession::get_State()->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions::TryCreateWithOutcome(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession&)->CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest::.ctor(CheatEngine.SDK.Engine.Enums.ScanOption,CheatEngine.SDK.Engine.Enums.RoundingType,string,string,boolean,boolean,boolean,boolean,boolean,string)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Tables.CheatTableFiles::TryLoad(string,boolean)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Tables.CheatTableFiles::TrySave(string)->CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetIdentityCheck::get_Kind()->CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetIdentityCheck::get_Observed()->CheatEngine.SDK.Engine.Targets.TargetSelectionObservation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation::get_ProcessId()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation::get_StartedAtUtcTicks()->int64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation::op_Equality(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation,CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation::op_Inequality(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation,CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome::get_Status()->CheatEngine.SDK.Engine.Targets.TargetReleaseStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetSelection::ObserveCurrent()->CheatEngine.SDK.Engine.Targets.TargetSelectionObservation", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetSelection::ValidateCurrent(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation)->CheatEngine.SDK.Engine.Targets.TargetIdentityCheck", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetSelectionObservation::get_Backend()->CheatEngine.SDK.Engine.Runtime.TargetBackend", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetSelectionObservation::get_Incarnation()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetSelectionObservation::get_SelectedProcessId()->System.Nullable`1", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Targets.TargetSelectionObservation::get_Status()->CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::.ctor(uint64)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::TryParse(string,CheatEngine.SDK.Engine.Values.Address&)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::get_IsZero()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::get_Value()->uint64", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::get_Zero()->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::op_Addition(CheatEngine.SDK.Engine.Values.Address,int64)->CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::op_GreaterThan(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Engine.Values.Address::op_LessThan(CheatEngine.SDK.Engine.Values.Address,CheatEngine.SDK.Engine.Values.Address)->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Bootstrap.PluginHost::get_Context()->CheatEngine.SDK.Hosting.Context.PluginContext", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Context.PluginContext::get_Epoch()->int32", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Context.PluginContext::get_IsCurrent()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Context.PluginContext::get_IsMainThread()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Context.PluginContext::get_ShutdownToken()->System.Threading.CancellationToken", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Threading.MainThread::Invoke``2(System.Func`2,!!0)->!!1", + "CheatEngine.Client.Core M CheatEngine.SDK.Hosting.Threading.MainThread::get_IsMainThread()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Calls.LuaError::FromStack(CheatEngine.SDK.Lua.State.LuaState,CheatEngine.SDK.Lua.Calls.LuaStatus)->CheatEngine.SDK.Lua.Calls.LuaError", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Calls.LuaError::get_Message()->string", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Calls.LuaOperationStatus::get_IsSuccess()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Calls.LuaOperationStatus::get_Kind()->CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Calls.LuaStatus::get_IsOk()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Runtime.LuaRuntime::TryAcquireOperationWithOutcome(CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation&)->CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Runtime.LuaRuntime::get_ExternalStateResetDetected()->boolean", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation::Dispose()->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation::get_State()->CheatEngine.SDK.Lua.State.LuaState", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.State.LuaFrame::.ctor(CheatEngine.SDK.Lua.State.LuaState)->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.State.LuaFrame::Dispose()->void", + "CheatEngine.Client.Core M CheatEngine.SDK.Lua.State.LuaState::TryExecute(System.ReadOnlySpan`1,int32,System.ReadOnlySpan`1)->CheatEngine.SDK.Lua.Calls.LuaStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.AddressList", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.AddressListAccess", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.AddressListMutations", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecord", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordId", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.AllocatedRegion", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetAllocationSize", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AssemblePreference", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionAssembler", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionAssembly", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionDisassembler", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionDisassembly", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionNavigator", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionProfile", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionProfiles", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Enums.FastScanMethod", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Enums.MemoryProtection", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Enums.RoundingType", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Enums.ScanOption", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Enums.VariableType", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Errors.EngineException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Errors.EngineFailureKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Errors.EngineMarshallingException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.EngineInspection", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.InspectionStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.MemorySize", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.ModuleInfo", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.ModuleName", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolExpression", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolInfo", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationHandoffException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolName", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.SymbolRegistry", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Memory.MemoryAccessFailure", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Memory.TargetMemory", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Objects.CEObject", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Objects.EngineEffectState", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Objects.Owned`1", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Objects.StringList", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Processes.CurrentProcessObservation", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Processes.ProcessOperationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Processes.RuntimeHostOperations", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Processes.RuntimeObservations", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.RuntimeInfo", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.TargetAbi", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Runtime.TargetBackend", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Aob.AobScanner", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Tables.CheatTableFiles", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetIdentityCheck", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetReleaseStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetSelection", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetSelectionObservation", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Core T CheatEngine.SDK.Hosting.Bootstrap.PluginHost", + "CheatEngine.Client.Core T CheatEngine.SDK.Hosting.Context.PluginContext", + "CheatEngine.Client.Core T CheatEngine.SDK.Hosting.Threading.MainThread", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Calls.LuaError", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Calls.LuaException", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Calls.LuaOperationStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Calls.LuaStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Marshalling.Int32Marshaller", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Runtime.LuaRuntime", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.State.LuaFrame", + "CheatEngine.Client.Core T CheatEngine.SDK.Lua.State.LuaState", + "CheatEngine.Client.Extensions.DependencyInjection T CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.Client.Fluent M CheatEngine.SDK.Engine.Inspection.ModuleName::.ctor(string)->void", + "CheatEngine.Client.Fluent M CheatEngine.SDK.Engine.Inspection.ModuleName::get_Value()->string", + "CheatEngine.Client.Fluent T CheatEngine.SDK.Engine.Inspection.ModuleName", + "CheatEngine.Client.Fluent T CheatEngine.SDK.Engine.Values.Address", + "CheatEngine.Client.Hosting M CheatEngine.SDK.Hosting.Diagnostics.HostLog::IsEnabled(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel)->boolean", + "CheatEngine.Client.Hosting M CheatEngine.SDK.Hosting.Diagnostics.HostLog::Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel,string,System.Exception)->void", + "CheatEngine.Client.Hosting M CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin::.ctor()->void", + "CheatEngine.Client.Hosting T CheatEngine.SDK.Hosting.Diagnostics.HostLog", + "CheatEngine.Client.Hosting T CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel", + "CheatEngine.Client.Hosting T CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin" + ]; +} diff --git a/tests/CheatEngine.Client.Tests/SdkContract/SdkApiUsage.cs b/tests/CheatEngine.Client.Tests/SdkContract/SdkApiUsage.cs new file mode 100644 index 0000000..3aa8b42 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/SdkContract/SdkApiUsage.cs @@ -0,0 +1,453 @@ +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Tables; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; +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.Calls; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.Client.Tests.SdkContract; + +/// +/// Compile-only map of every member of the consumed CheatEngine.SDK (the pin of eng/CheatEngineSdk.props) +/// that the shipped Client assemblies consume (Q48). +/// +/// +/// +/// Never call these methods. They exist so that a candidate SDK package that renames, removes, or changes the +/// signature of a consumed member fails to compile here, at an exact location, before any Client package +/// is published. SdkConsumerContractTests.SdkApiUsageCoversEveryConsumedSdkMember proves that this map covers +/// the committed inventory in . +/// +/// +/// Excluded: CheatEngine.SDK.Lua.CompilerServices.* helpers. Only code that the SDK LuaBindings generator +/// emits calls them, so a change to them is caught by compiling that code against the candidate package; they +/// would remain in the inventory. The Client declares no [LuaGlobal] binding any more, so none is consumed +/// today. +/// +/// +internal static class SdkApiUsage +{ + internal static void AddressListSurface(AddressList list, MemoryRecord record, MemoryRecordId id, + MemoryRecordId otherId) + { + _ = list.TryCreateMemoryRecord(out MemoryRecord created); + _ = list.TryGetCount(out int count); + _ = list.TryGetMemoryRecord(count, out _); + _ = list.TryGetMemoryRecordById(id, out _); + _ = list.TryGetSelectedRecord(out _); + _ = list.TrySetSelectedRecord(created); + _ = AddressListAccess.TryGetCurrent(out _); + _ = record.TryGetActive(out _); + _ = record.TryGetAddressExpression(out _); + _ = record.TryGetAsync(out _); + _ = record.TryGetAsyncProcessing(out _); + _ = record.TryGetChild(0, out _); + _ = record.TryGetCurrentAddress(out _); + _ = record.TryGetDescription(out _); + _ = record.TryGetId(out _); + _ = record.TryGetIndex(out _); + _ = record.TryGetOffsetCount(out _); + _ = record.TryGetScript(out _); + _ = record.TryGetValue(out _); + _ = record.TryGetVariableType(out _); + _ = record.TrySetAddressExpression("expression"); + _ = record.TrySetDescription("description"); + _ = record.TrySetValue("value"); + _ = record.TrySetVariableType(VariableType.Dword); + _ = record.Handle; + _ = id == otherId; + _ = id.Value; + } + + internal static void AddressListMutationSurface(MemoryRecordId id, MemoryRecordId? parentId) + { + MemoryRecordMutationOutcome deleted = AddressListMutations.Delete(id); + _ = deleted.Effect; + _ = deleted.IsCompleted; + _ = deleted.Problem; + _ = AddressListMutations.SetParent(id, parentId, new MemoryRecordParentTraversalLimit(1)); + MemoryRecordActivationOutcome activation = AddressListMutations.SetActive(id, true); + _ = activation.Kind; + _ = activation.Problem; + } + + internal static void TableFileSurface() + { + _ = CheatTableFiles.TryLoad("table.ct", false); + _ = CheatTableFiles.TrySave("table.ct"); + } + + internal static void ObjectSurface(CEObject handle, Owned owner, StringList list) + { + _ = handle.TryGetProperty("Count"u8, out _); + _ = owner.Value; + TargetReleaseOutcome released = owner.ReleaseWithOutcome(); + _ = released.Status; + _ = list.TryGetCount(out _); + _ = list.TryGetItem(0, out _); + } + + internal static void InspectionSurface(Address address, ModuleName moduleName, SymbolExpression expression, + AddressResolutionOptions options, ModuleInfo module, MemorySize size, TargetProcessId processId, + TargetProcessId otherProcessId) + { + ModuleInfo[] modules = []; + _ = EngineInspection.EnumerateMemoryRegions(Array.Empty(), out _); + _ = EngineInspection.EnumerateModules(processId, modules, out _); + _ = EngineInspection.EnumerateModules(modules, out _); + _ = EngineInspection.EnumerateSections(moduleName, Array.Empty(), out _); + _ = EngineInspection.GetMemoryRegionInfo(address, out _); + _ = EngineInspection.GetSymbolInfo(expression, out _); + _ = EngineInspection.ResolveAddress(expression, options, out _); + _ = new AddressResolutionOptions(true); + _ = size.Value; + _ = module.BaseAddress; + _ = module.ImageSize; + _ = module.Name; + _ = moduleName.Value; + _ = new ModuleName("module"); + _ = new SymbolExpression("symbol"); + _ = expression.Value; + _ = new TargetProcessId(1); + _ = processId.Value; + _ = processId != otherProcessId; + } + + internal static void SymbolRegistrySurface(Address address, SymbolRegistrationLease lease) + { + SymbolRegistrationAcquireOutcome outcome = SymbolRegistry.TryRegisterOwned(new SymbolName("symbol"), address, + new SymbolRegistrationOptions(true)); + _ = outcome.Lease; + _ = outcome.Status; + _ = lease.Release().Kind; + _ = SymbolRegistry.TryGetName(address, out _); + } + + internal static void MemorySurface(Address address) + { + Span bytes = stackalloc byte[1]; + _ = TargetMemory.TryReadBytes(address, bytes, out int _, out MemoryAccessFailure _); + _ = TargetMemory.TryReadDouble(address, out _, out _); + _ = TargetMemory.TryReadInt16(address, out _, out _); + _ = TargetMemory.TryReadInt32(address, out _, out _); + _ = TargetMemory.TryReadInt64(address, out _, out _); + _ = TargetMemory.TryReadInt8(address, out _, out _); + _ = TargetMemory.TryReadPointer(address, PointerSize.Bit64, out _, out _); + _ = TargetMemory.TryReadSingle(address, out _, out _); + _ = TargetMemory.TryReadString(address, 1, false, out _, out _); + _ = TargetMemory.TryReadUInt16(address, out _, out _); + _ = TargetMemory.TryReadUInt32(address, out _, out _); + _ = TargetMemory.TryReadUInt64(address, out _, out _); + _ = TargetMemory.TryReadUInt8(address, out _, out _); + _ = TargetMemory.TryWriteBytes(address, ReadOnlySpan.Empty, out _); + _ = TargetMemory.TryWriteDouble(address, 0d, out _); + _ = TargetMemory.TryWriteInt16(address, 0, out _); + _ = TargetMemory.TryWriteInt32(address, 0, out _); + _ = TargetMemory.TryWriteInt64(address, 0L, out _); + _ = TargetMemory.TryWriteInt8(address, 0, out _); + _ = TargetMemory.TryWritePointer(address, address, PointerSize.Bit64, out _); + _ = TargetMemory.TryWriteSingle(address, 0f, out _); + _ = TargetMemory.TryWriteString(address, ReadOnlySpan.Empty, false, out _); + _ = TargetMemory.TryWriteUInt16(address, 0, out _); + _ = TargetMemory.TryWriteUInt32(address, 0u, out _); + _ = TargetMemory.TryWriteUInt64(address, 0ul, out _); + _ = TargetMemory.TryWriteUInt8(address, 0, out _); + } + + internal static void RuntimeSurface(CheatEngineVersion version, PointerSize pointerSize, RuntimeCapabilityId id, + RuntimeCapabilities capabilities, RuntimeInfo info, CheatEngineHostObservation host, + TargetArchitectureObservation target) + { + _ = version.Major; + _ = version.Minor; + _ = CheatEngineVersion.Ce77010621; + _ = version != CheatEngineVersion.Ce77010621; + _ = PointerSize.Bit32; + _ = PointerSize.Bit64; + _ = PointerSize.Unknown; + _ = pointerSize.Bytes; + _ = pointerSize.IsKnown; + _ = pointerSize != PointerSize.Bit32; + _ = pointerSize == PointerSize.Bit64; + _ = capabilities.TryGet(id, out RuntimeCapabilityAvailability availability); + _ = availability.State; + _ = capabilities.GetState(id); + _ = RuntimeCapabilityId.CurrentProcess; + _ = RuntimeCapabilityId.TargetArchitecture; + _ = info.Capabilities; + _ = info.Host; + _ = info.Target; + _ = new CheatEngineHostObservation(version, CheatEngineArchitecture.X64, true, + CheatEngineOperatingSystem.Windows); + _ = host.CheatEngineIs64Bit; + _ = host.FileVersion; + _ = host.OperatingSystem; + _ = host.SystemArchitecture; + _ = new TargetArchitectureObservation(new TargetProcessId(1), TargetBackend.LocalProcess, pointerSize, true, + false, false, 0, 8); + _ = target.Abi; + _ = target.Architecture; + _ = target.Backend; + _ = target.Bitness; + _ = target.ConfiguredPointerSize; + _ = target.ConfiguredPointerSizeBytes; + _ = target.ConfiguredPointerSizeDiffersFromBitness; + _ = target.IsAndroid; + _ = target.ProcessId; + } + + internal static void RuntimeObservationSurface(ProcessOperationStatus status, LuaOperationStatus luaStatus, + CurrentProcessObservation current, TargetProcessId processId, TargetProcessId otherProcessId) + { + _ = RuntimeObservations.TryObserveRuntimeInfo(out _); + _ = RuntimeHostOperations.ObserveHost(out _); + _ = RuntimeHostOperations.TryGetCheatEngineFileVersion(out _); + _ = RuntimeHostOperations.TryGetOperatingSystem(out _); + _ = RuntimeHostOperations.TryGetSystemArchitecture(out _); + _ = RuntimeHostOperations.TryIsCheatEngine64Bit(out _); + _ = RuntimeProcessOperations.ObserveCurrent(out _); + _ = RuntimeProcessOperations.ObserveTargetArchitecture(out _); + _ = RuntimeProcessOperations.TryGetConfiguredPointerSize(out _, out _); + _ = RuntimeProcessOperations.SelectAndObserve(processId, out _); + _ = ProcessOperationStatus.GlobalUnavailable; + _ = ProcessOperationStatus.Success; + _ = ProcessOperationStatus.TargetChanged; + _ = ProcessOperationStatus.TargetNotAttached; + _ = status.IsSuccess; + _ = status.Kind; + _ = luaStatus.IsSuccess; + _ = luaStatus.Kind; + _ = current.Id; + _ = current.PointerSize; + _ = processId == otherProcessId; + } + + internal static void TargetSelectionSurface(TargetProcessIncarnation incarnation, TargetProcessIncarnation other) + { + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + TargetIdentityCheck check = TargetSelection.ValidateCurrent(incarnation); + _ = check.Kind; + _ = check.Observed; + _ = observation.Backend; + _ = observation.Incarnation; + _ = observation.SelectedProcessId; + _ = observation.Status; + _ = incarnation.ProcessId; + _ = incarnation.StartedAtUtcTicks; + _ = incarnation != other; + _ = incarnation == other; + } + + internal static void ScanningAndValueSurface(AobScanOptions options, Address address, Address other, + ModuleInfo module) + { + _ = new AobScanOptions("+X-C-W", FastScanMethod.Aligned, "4"); + AobScanOutcome outcome = AobScanner.TryScanOutcome("90", options, out _, out AobScanTargetContext context); + _ = outcome.Kind; + _ = outcome.LuaStatus; + _ = outcome.ResultCount; + _ = context.Before; + _ = context.After; + _ = new Address(0x1000UL); + _ = AobScanBounds.TryFromModule(in module, out _); + _ = AobScanBounds.TryCreate(address, other, out AobScanBounds bounds); + _ = bounds.Start; + _ = bounds.Stop; + AobBoundedScanResult bounded = + AobScanner.TryScanWithinBounds("90", bounds, options, new Address[2], CancellationToken.None); + _ = bounded.Kind; + _ = bounded.Creation.Status; + _ = bounded.LuaStatus; + _ = bounded.HostResultCount; + _ = bounded.Written; + _ = bounded.RowsRead; + _ = bounded.UnreadHostRows; + _ = bounded.BelowStartSkipped; + _ = bounded.AtOrAfterStopSkipped; + _ = bounded.IsMaterializationLimitReached; + _ = bounded.HostErrorText; + _ = bounded.IsHostErrorTextTruncated; + _ = bounded.IsHostErrorTextUnreadable; + _ = bounded.HostScanElapsed; + _ = bounded.CopyElapsed; + MemoryScanReleaseOutcome release = bounded.Release; + _ = release.FoundList; + _ = release.MemScan; + _ = release.Termination; + _ = Address.FromUInt64(0); + _ = Address.TryParse("400000", out _); + _ = address.Value; + _ = Address.Zero; + _ = address + 1L; + _ = address < other; + _ = address <= other; + _ = address >= other; + } + + internal static void HostingSurface(PluginContext context) + { + _ = PluginHost.Context; + _ = context.Epoch; + _ = context.IsCurrent; + _ = context.IsMainThread; + _ = context.ShutdownToken; + _ = MainThread.Invoke(static (int state) => state, 1); + _ = MainThread.IsMainThread; + _ = new CompileOnlyPlugin(); + _ = HostLog.IsEnabled(HostLogLevel.Trace); + HostLog.Write(HostLogLevel.Error, string.Empty, null); + } + + internal static void AutoAssemblerSurface(AutoAssemblerApplyOutcome applied, AutoAssemblerCheckOutcome checkedScript, + AutoAssemblerPatch patch, TargetReleaseOutcome release) + { + AutoAssemblerOptions options = new() + { + CaptureHostText = true, + MaxHostTextBytes = 1, + MaxDisableInfoEntries = 1, + MaxDisableInfoNameBytes = 1 + }; + _ = AutoAssemblerPatcher.TryApplyWithOutcome("[ENABLE]", options, out _); + _ = AutoAssemblerPatcher.TryCheck("[ENABLE]", true, options); + _ = applied.Compensation; + _ = applied.Effect; + _ = applied.HostText; + _ = applied.HostTextTruncated; + _ = applied.HostWarnings; + _ = applied.HostWarningsTruncated; + _ = applied.Kind; + _ = applied.LuaStatus; + _ = checkedScript.HostText; + _ = checkedScript.HostTextTruncated; + _ = checkedScript.Kind; + _ = checkedScript.LuaStatus; + _ = patch.IsDisposed; + _ = patch.IsEnabled; + _ = patch.LastReleaseOutcome; + _ = patch.RequiresManualRecovery; + _ = patch.TargetIncarnation; + _ = patch.ReleaseWithTargetOutcome(); + _ = release.Status; + } + + internal static void InstructionSurface(InstructionTargetProfile target, InstructionAssembly assembly, + InstructionDisassembly disassembly) + { + _ = InstructionProfiles.TryObserveCurrent(out _); + _ = target.Target; + _ = target.Profile.Architecture; + _ = target.Profile.AddressWidth; + _ = InstructionAssembler.TryAssemble(target, "nop", Address.Zero, AssemblePreference.None, false, + Span.Empty, out _); + _ = assembly.Written; + _ = assembly.RequiredLength; + _ = InstructionDisassembler.TryDisassemble(target, Address.Zero, 1, out _, out _); + _ = disassembly.AddressText; + _ = disassembly.Opcode; + _ = disassembly.Extra; + _ = InstructionNavigator.TryGetLength(target, Address.Zero, out _); + _ = InstructionNavigator.TryGetPrevious(target, Address.Zero, out _); + } + + internal static void LuaSurface(LuaState state, LuaStatus status) + { + LuaError error = LuaError.FromStack(state, status); + _ = error.Message; + _ = status.IsOk; + AddressMarshaller.Push(state, 0); + _ = StringMarshaller.TryRead(state, -1, out _); + _ = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + _ = LuaRuntime.ExternalStateResetDetected; + _ = operation.State; + operation.Dispose(); + using LuaFrame frame = new(state); + _ = state.TryExecute(ReadOnlySpan.Empty, 0, ReadOnlySpan.Empty); + _ = new EngineMarshallingException("operation", EngineMarshallingDirection.Result, "expected", "actual"); + } + + internal static void FailureSurface(EngineException engineFailure, MemoryScanException scanFailure) + { + _ = engineFailure.Kind; + _ = scanFailure.FailureKind; + } + + internal static void ValueScanSurface(MemoryScanSession session, MemoryScanCreationOutcome creation, + MemoryScanReleaseOutcome release, MemoryScanResult result, TargetReleaseOutcome target, Address address, + Address other) + { + FirstScanRequest first = new(ScanOption.ExactValue, VariableType.Dword, RoundingType.Rounded, "1", + string.Empty, new Address(0UL), address, string.Empty, FastScanMethod.NotAligned, string.Empty, false, + false, false, false); + NextScanRequest next = new(ScanOption.Changed, RoundingType.Rounded, string.Empty, string.Empty, false, false, + false, false, false, null); + Span page = new MemoryScanResult[1]; + _ = MemoryScanSessions.TryCreateWithOutcome(out _); + _ = creation.Status; + _ = creation.TargetObservation; + session.StartFirstScanCancellable(in first, CancellationToken.None); + session.StartNextScanCancellable(in next, CancellationToken.None); + session.WaitForCompletionCancellable(CancellationToken.None); + session.ResetCancellable(CancellationToken.None); + _ = session.TryCopyResultsPageCancellable(0, page, out _, out _, CancellationToken.None); + _ = session.TryGetHostErrorText(out _, out _); + _ = session.ResultCount; + _ = session.State; + _ = session.InvalidationReason; + _ = session.LastCancellationMilestone; + _ = session.ReleaseWithOutcome(); + _ = release.FoundList; + _ = release.MemScan; + _ = release.Termination; + _ = target.Status; + _ = result.Address; + _ = result.Value; + _ = address > other; + } + + internal static void AllocationSurface(TargetAllocationAcquireOutcome outcome, AllocatedRegion region, + Address address) + { + TargetAllocationRequest request = new(new TargetAllocationSize(16), address, MemoryProtection.ReadWrite); + _ = new TargetMemoryAllocator().TryAllocate(request, out _); + _ = outcome.Allocation.Operation.Kind; + _ = outcome.Allocation.Address; + _ = outcome.Effect; + _ = outcome.Compensation; + _ = outcome.HasOwner; + _ = region.ReleaseWithTargetOutcome(); + _ = region.IsDisposed; + _ = region.LastReleaseOutcome; + _ = region.TargetIncarnation; + _ = address.IsZero; + } + + private sealed class CompileOnlyPlugin : CheatEnginePlugin + { + protected override void OnEnable() + { + } + + protected override void OnDisable() + { + } + } +} diff --git a/tests/CheatEngine.Client.Tests/SdkContract/SdkConsumerContractTests.cs b/tests/CheatEngine.Client.Tests/SdkContract/SdkConsumerContractTests.cs new file mode 100644 index 0000000..fb6d4c8 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/SdkContract/SdkConsumerContractTests.cs @@ -0,0 +1,161 @@ +using System.Reflection.Metadata; + +using CheatEngine.Client.SourceGenerators.Lua; +using CheatEngine.Client.Tests.Architecture; +using CheatEngine.SDK.Engine.Values; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests.SdkContract; + +/// +/// Consumer contracts against the consumed CheatEngine.SDK package, the pin of eng/CheatEngineSdk.props +/// (audit Q48, ADR-10, A11-17, A11-30): an SDK change that the Client did not adapt to is detected here, before +/// publication, instead of by a plugin at runtime. +/// +/// +/// Q48 is Partial by decision: the Client has no next-SDK build leg, and no job builds it against an +/// unreleased CheatEngine.SDK. Q48 is covered by the consumer-contract tests only: these C1 tests, the compile-only +/// map, and the CHEATENGINECLIENT9016 version-range guard. +/// +public sealed class SdkConsumerContractTests +{ + private const string CompilerServicesNamespace = "CheatEngine.SDK.Lua.CompilerServices."; + + [Fact] + [Trait("Qualification", "Q48")] + public void AllowlistedSdkTypesResolveAndBothAllowlistsAreEqual() + { + ReflectionAssembly[] sdkAssemblies = GetReferencedSdkAssemblies(); + List unresolved = []; + foreach (string name in ApprovedSdkClientTypes.Names) + { + Type? type = sdkAssemblies.Select(assembly => assembly.GetType(name, throwOnError: false)) + .FirstOrDefault(static candidate => candidate is not null); + if (type is null || !type.IsPublic || !type.IsValueType) + { + unresolved.Add(name); + } + } + + // A stale entry, such as an SDK AOB pattern type the consumed package does not ship, fails here. + Assert.True(unresolved.Count == 0, + "These allowlisted SDK types do not resolve to public value types in the consumed CheatEngine.SDK package: " + + string.Join(", ", unresolved)); + Assert.Equal(ApprovedSdkClientTypes.Names.Order(StringComparer.Ordinal), ApprovedSdkClientTypes.Names); + Assert.Equal(ApprovedSdkClientTypes.Names.Length, + ApprovedSdkClientTypes.Names.Distinct(StringComparer.Ordinal).Count()); + AssertTheGeneratorUsesTheSharedAllowlistFile(); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void PublicSdkTypeReferencesOfAbstractionsFluentAndDependencyInjectionAreAllowlisted() + { + HashSet allowed = new(ApprovedSdkClientTypes.Names, StringComparer.Ordinal); + List violations = []; + foreach (string assembly in new[] + { + "CheatEngine.Client.Abstractions", "CheatEngine.Client.Fluent", + "CheatEngine.Client.Extensions.DependencyInjection" + }) + { + ClientAssemblyCatalog.ReadMetadata(assembly, (reader, _) => + { + foreach (TypeReferenceHandle handle in reader.TypeReferences) + { + MetadataSurface.TypeIdentity type = MetadataSurface.ResolveType(reader, handle); + if (type.IsSdk && !allowed.Contains(type.FullName)) + { + violations.Add($"{assembly} references non-allowlisted SDK type {type.FullName}."); + } + } + }); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void ConsumedSdkSurfaceMatchesTheCommittedInventory() + { + string[] actual = SdkSurfaceReader.ReadConsumedSurface(); + string[] added = actual.Except(ConsumedSdkSurface.Lines, StringComparer.Ordinal).ToArray(); + string[] removed = ConsumedSdkSurface.Lines.Except(actual, StringComparer.Ordinal).ToArray(); + + Assert.True(added.Length == 0 && removed.Length == 0, + "The CheatEngine.SDK surface consumed by the Client changed. Review the change, then update " + + "tests/CheatEngine.Client.Tests/SdkContract/ConsumedSdkSurface.cs (and SdkApiUsage.cs for a new member)." + + Environment.NewLine + "Added:" + Environment.NewLine + string.Join(Environment.NewLine, added) + + Environment.NewLine + "Removed:" + Environment.NewLine + string.Join(Environment.NewLine, removed)); + Assert.Equal(ConsumedSdkSurface.Lines.Order(StringComparer.Ordinal), ConsumedSdkSurface.Lines); + } + + [Fact] + [Trait("Qualification", "Q48")] + public void SdkApiUsageCoversEveryConsumedSdkMember() + { + HashSet covered = new( + SdkSurfaceReader.ReadMemberReferencesFrom(typeof(SdkApiUsage).Assembly.Location, + typeof(SdkApiUsage).FullName!), + StringComparer.Ordinal); + string[] consumedMembers = ConsumedSdkSurface.Lines + .Select(static line => line.Split(' ', 3)) + .Where(static parts => parts[1] == "M" && + !parts[2].StartsWith(CompilerServicesNamespace, StringComparison.Ordinal)) + .Select(static parts => parts[2]) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + string[] missing = consumedMembers.Where(member => !covered.Contains(member)).ToArray(); + + Assert.NotEmpty(consumedMembers); + Assert.True(missing.Length == 0, + "SdkApiUsage.cs does not reference these consumed SDK members with their exact signature:" + + Environment.NewLine + string.Join(Environment.NewLine, missing)); + } + + private static ReflectionAssembly[] GetReferencedSdkAssemblies() + { + return + [ + .. ClientAssemblyCatalog.LoadAll() + .SelectMany(static assembly => assembly.GetReferencedAssemblies()) + .Where(static name => name.Name is not null && MetadataSurface.IsSdkAssembly(name.Name)) + .DistinctBy(static name => name.Name, StringComparer.Ordinal) + .Select(ReflectionAssembly.Load), + typeof(Address).Assembly + ]; + } + + /// Both consumers compile the same file; this guards against a second, drifting copy in the generator. + private static void AssertTheGeneratorUsesTheSharedAllowlistFile() + { + string root = FindRepositoryRoot(); + string generator = File.ReadAllText(Path.Combine(root, "source-generators", + "CheatEngine.Client.SourceGenerators.Lua", "CheatEngineLuaGenerator.cs")); + string project = File.ReadAllText(Path.Combine(root, "tests", "CheatEngine.Client.Tests", + "CheatEngine.Client.Tests.csproj")); + + Assert.Contains("ApprovedSdkClientTypes.Names", generator, StringComparison.Ordinal); + Assert.DoesNotContain("\"CheatEngine.SDK.Engine.Values.Address\"", generator, StringComparison.Ordinal); + Assert.Contains("CheatEngine.Client.SourceGenerators.Lua/ApprovedSdkClientTypes.cs", project, + StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "CheatEngine.Client.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("CheatEngine.Client.slnx was not found above the test output directory."); + } +} diff --git a/tests/CheatEngine.Client.Tests/SdkContract/SdkSurfaceReader.cs b/tests/CheatEngine.Client.Tests/SdkContract/SdkSurfaceReader.cs new file mode 100644 index 0000000..2b8f485 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/SdkContract/SdkSurfaceReader.cs @@ -0,0 +1,70 @@ +using System.Reflection.Metadata; + +using CheatEngine.Client.Tests.Architecture; + +namespace CheatEngine.Client.Tests.SdkContract; + +/// Computes the CheatEngine.SDK surface that the compiled Client assemblies actually consume. +internal static class SdkSurfaceReader +{ + /// + /// Returns one sorted line per SDK type reference (T) and member reference (M) of each shipped Client + /// assembly, prefixed with the consuming assembly name. + /// + internal static string[] ReadConsumedSurface() + { + SortedSet lines = new(StringComparer.Ordinal); + foreach (string consumer in ClientAssemblyCatalog.Names) + { + ClientAssemblyCatalog.ReadMetadata(consumer, (reader, _) => + { + foreach (TypeReferenceHandle handle in reader.TypeReferences) + { + MetadataSurface.TypeIdentity type = MetadataSurface.ResolveType(reader, handle); + if (type.IsSdk) + { + lines.Add($"{consumer} T {type.FullName}"); + } + } + + foreach (MemberReferenceHandle handle in reader.MemberReferences) + { + string member = MetadataSurface.DescribeMember(reader, handle, + out MetadataSurface.TypeIdentity declaringType); + if (declaringType.IsSdk) + { + lines.Add($"{consumer} M {member}"); + } + } + }); + } + + return [.. lines]; + } + + /// Returns the SDK member references made from method bodies of one type of an assembly. + internal static string[] ReadMemberReferencesFrom(string assemblyPath, string outermostTypeFullName) + { + SortedSet members = new(StringComparer.Ordinal); + using FileStream stream = File.OpenRead(assemblyPath); + using System.Reflection.PortableExecutable.PEReader peReader = new(stream); + MetadataReader reader = peReader.GetMetadataReader(); + foreach (MetadataSurface.IlReference reference in MetadataSurface.ReadIlReferences(reader, peReader)) + { + if (!string.Equals(reference.OuterType, outermostTypeFullName, StringComparison.Ordinal) || + MetadataSurface.AsMemberReference(reader, reference.Token) is not { } memberHandle) + { + continue; + } + + string member = MetadataSurface.DescribeMember(reader, memberHandle, + out MetadataSurface.TypeIdentity declaringType); + if (declaringType.IsSdk) + { + members.Add(member); + } + } + + return [.. members]; + } +} diff --git a/tests/CheatEngine.Client.Tests/packages.lock.json b/tests/CheatEngine.Client.Tests/packages.lock.json index 5a92361..a203895 100644 --- a/tests/CheatEngine.Client.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Tests/packages.lock.json @@ -2,17 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.401, )", - "resolved": "10.0.401", - "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.401", - "Microsoft.SourceLink.Common": "10.0.401", - "System.IO.Hashing": "10.0.12" - } - }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.11.2, )", @@ -24,6 +13,35 @@ "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, )", @@ -34,6 +52,12 @@ "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, )", @@ -55,12 +79,12 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", "dependencies": { - "System.IO.Hashing": "10.0.12" + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" } }, "Microsoft.DiaSymReader": { @@ -96,11 +120,6 @@ "resolved": "10.0.12", "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.401", - "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", @@ -136,11 +155,6 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.1", @@ -207,28 +221,28 @@ "cheatengine.client": { "type": "Project", "dependencies": { - "CheatEngine.Client.Fluent": "[0.1.0, )", - "CheatEngine.Client.Hosting": "[0.1.0, )" + "CheatEngine.Client.Fluent": "[1.0.0, )", + "CheatEngine.Client.Hosting": "[1.0.0, )" } }, "cheatengine.client.abstractions": { "type": "Project", "dependencies": { - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.core": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)" + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)" } }, "cheatengine.client.extensions.dependencyinjection": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )", - "CheatEngine.Client.Core": "[0.1.0, )", + "CheatEngine.Client.Abstractions": "[1.0.0, )", + "CheatEngine.Client.Core": "[1.0.0, )", "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )", @@ -239,23 +253,23 @@ "cheatengine.client.fluent": { "type": "Project", "dependencies": { - "CheatEngine.Client.Abstractions": "[0.1.0, )" + "CheatEngine.Client.Abstractions": "[1.0.0, )" } }, "cheatengine.client.hosting": { "type": "Project", "dependencies": { - "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", - "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "CheatEngine.Client.Extensions.DependencyInjection": "[1.0.0, )", + "CheatEngine.SDK": "[2.0.0, 3.0.0)", "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", "Microsoft.Extensions.Logging": "[10.0.12, )" } }, "CheatEngine.SDK": { "type": "CentralTransitive", - "requested": "[1.0.0, )", - "resolved": "1.0.0", - "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "NLEdZYJ9LKW3EFNB4X5snKCQf7ZS86GkCQ+El7o+S1XQcxHQGjS45Q1ap8lfjQuIwm004mQ3TPxo+ph1yvRrlQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive",